Refactor extensions to async/sync API

Redesign extension handling to support explicit async/sync execution. helpers/extension.py rewrites the extensible decorator, adds call_extensions_async / call_extensions_sync, and a helper to gather extension classes; caching flag adjusted. Updated call sites across the codebase (agent, APIs, plugins, tools, settings, extensions) to use extension.extensible and the new call_extensions_async/sync API, and converted several extension handlers from async to sync. Also small frontend tweaks (use globalThis.runtimeInfo) and minor import updates (csrf_protect in run_ui). This centralizes extension discovery/execution and avoids previously scattered asyncio.run usage.

frdel committed Mar 6, 2026 at 11:32 UTC ab9fc4ee7f3d011069a6027b717c6d7db61a897c
14 files changed +237 -182
agent.py
+82 -69
@@ -30,7 +30,7 @@ from helpers.dirty_json import DirtyJson
30 from helpers.defer import DeferredTask
31 from typing import Callable
32 from helpers.localization import Localization
33 -from helpers.extension import call_extensions, extensible
33 +from helpers import extension
34 from helpers.errors import RepairableException, InterventionException, HandledException
35
36 class AgentContextType(Enum):
@@ -46,7 +46,7 @@ class AgentContext:
46 _counter: int = 0
47 _notification_manager = None
48
49 - @extensible
49 + @extension.extensible
50 def __init__(
51 self,
52 config: "AgentConfig",
@@ -152,7 +152,7 @@ class AgentContext:
152 return cls._notification_manager
153
154 @staticmethod
155 - @extensible
155 + @extension.extensible
156 def remove(id: str):
157 with AgentContext._contexts_lock:
158 context = AgentContext._contexts.pop(id, None)
@@ -176,7 +176,7 @@ class AgentContext:
176 # recursive is not used now, prepared for context hierarchy
177 self.output_data[key] = value
178
179 - # @extensible
179 + @extension.extensible
180 def output(self):
181 return {
182 "id": self.id,
@@ -220,12 +220,12 @@ class AgentContext:
220 )
221 return items
222
223 - @extensible
223 + @extension.extensible
224 def kill_process(self):
225 if self.task:
226 self.task.kill()
227
228 - @extensible
228 + @extension.extensible
229 def reset(self):
230 self.kill_process()
231 self.log.reset()
@@ -233,21 +233,21 @@ class AgentContext:
233 self.streaming_agent = None
234 self.paused = False
235
236 - @extensible
236 + @extension.extensible
237 def nudge(self):
238 self.kill_process()
239 self.paused = False
240 self.task = self.communicate(UserMessage(self.agent0.read_prompt("fw.msg_nudge.md")))
241 return self.task
242
243 - @extensible
243 + @extension.extensible
244 def get_agent(self):
245 return self.streaming_agent or self.agent0
246
247 def is_running(self) -> bool:
248 return (self.task and self.task.is_alive()) or False
249
250 - @extensible
250 + @extension.extensible
251 def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
252 self.paused = False # unpause if paused
253
@@ -267,7 +267,7 @@ class AgentContext:
267
268 return self.task
269
270 - @extensible
270 + @extension.extensible
271 def run_task(
272 self, func: Callable[..., Coroutine[Any, Any, Any]], *args: Any, **kwargs: Any
273 ):
@@ -279,7 +279,7 @@ class AgentContext:
279 return self.task
280
281 # this wrapper ensures that superior agents are called back if the chat was loaded from file and original callstack is gone
282 - @extensible
282 + @extension.extensible
283 async def _process_chain(self, agent: "Agent", msg: "UserMessage|str", user=True):
284 try:
285 msg_template = (
@@ -295,13 +295,13 @@ class AgentContext:
295 response = await self._process_chain(superior, response, False) # type: ignore
296
297 # call end of process extensions
298 - await self.get_agent().call_extensions("process_chain_end", data={})
298 + await extension.call_extensions_async("process_chain_end", agent=self.get_agent(), data={})
299
300 return response
301 except Exception as e:
302 await self.handle_exception("process_chain", e)
303
304 - @extensible
304 + @extension.extensible
305 async def handle_exception(self, location: str, exception: Exception):
306 if exception:
307 raise exception # exception handling is done by extensions
@@ -358,7 +358,7 @@ class Agent:
358 DATA_NAME_SUBORDINATE = "_subordinate"
359 DATA_NAME_CTX_WINDOW = "ctx_window"
360
361 - @extensible
361 + @extension.extensible
362 def __init__(
363 self, number: int, config: AgentConfig, context: AgentContext | None = None
364 ):
@@ -378,16 +378,18 @@ class Agent:
378 self.intervention: UserMessage | None = None
379 self.data: dict[str, Any] = {} # free data object all the tools can use
380
381 - asyncio.run(self.call_extensions("agent_init"))
381 + extension.call_extensions_sync("agent_init", self)
382
383 - @extensible
383 + @extension.extensible
384 async def monologue(self):
385 while True:
386 try:
387 # loop data dictionary to pass to extensions
388 self.loop_data = LoopData(user_message=self.last_user_message)
389 # call monologue_start extensions
390 - await self.call_extensions("monologue_start", loop_data=self.loop_data)
390 + await extension.call_extensions_async(
391 + "monologue_start", self, loop_data=self.loop_data
392 + )
393
394 printer = PrintStyle(italic=True, font_color="#b3ffd9", padding=False)
395
@@ -399,8 +401,8 @@ class Agent:
401 self.loop_data.params_temporary = {} # clear temporary params
402
403 # call message_loop_start extensions
402 - await self.call_extensions(
403 - "message_loop_start", loop_data=self.loop_data
404 + await extension.call_extensions_async(
405 + "message_loop_start", self, loop_data=self.loop_data
406 )
407 await self.handle_intervention()
408
@@ -409,8 +411,8 @@ class Agent:
411 prompt = await self.prepare_prompt(loop_data=self.loop_data)
412
413 # call before_main_llm_call extensions
412 - await self.call_extensions(
413 - "before_main_llm_call", loop_data=self.loop_data
414 + await extension.call_extensions_async(
415 + "before_main_llm_call", self, loop_data=self.loop_data
416 )
417 await self.handle_intervention()
418
@@ -421,8 +423,9 @@ class Agent:
423 printer.print("Reasoning: ") # start of reasoning
424 # Pass chunk and full data to extensions for processing
425 stream_data = {"chunk": chunk, "full": full}
424 - await self.call_extensions(
426 + await extension.call_extensions_async(
427 "reasoning_stream_chunk",
428 + self,
429 loop_data=self.loop_data,
430 stream_data=stream_data,
431 )
@@ -439,8 +442,9 @@ class Agent:
442 printer.print("Response: ") # start of response
443 # Pass chunk and full data to extensions for processing
444 stream_data = {"chunk": chunk, "full": full}
442 - await self.call_extensions(
445 + await extension.call_extensions_async(
446 "response_stream_chunk",
447 + self,
448 loop_data=self.loop_data,
449 stream_data=stream_data,
450 )
@@ -459,13 +463,13 @@ class Agent:
463 await self.handle_intervention(agent_response)
464
465 # Notify extensions to finalize their stream filters
462 - await self.call_extensions(
463 - "reasoning_stream_end", loop_data=self.loop_data
466 + await extension.call_extensions_async(
467 + "reasoning_stream_end", self, loop_data=self.loop_data
468 )
469 await self.handle_intervention(agent_response)
470
467 - await self.call_extensions(
468 - "response_stream_end", loop_data=self.loop_data
471 + await extension.call_extensions_async(
472 + "response_stream_end", self, loop_data=self.loop_data
473 )
474
475 await self.handle_intervention(agent_response)
@@ -498,8 +502,8 @@ class Agent:
502 finally:
503 # call message_loop_end extensions
504 if self.context.task and self.context.task.is_alive(): # don't call extensions post mortem
501 - await self.call_extensions(
502 - "message_loop_end", loop_data=self.loop_data
505 + await extension.call_extensions_async(
506 + "message_loop_end", self, loop_data=self.loop_data
507 )
508
509
@@ -511,21 +515,27 @@ class Agent:
515 self.context.streaming_agent = None # unset current streamer
516 # call monologue_end extensions
517 if self.context.task and self.context.task.is_alive(): # don't call extensions post mortem
514 - await self.call_extensions("monologue_end", loop_data=self.loop_data) # type: ignore
518 + await extension.call_extensions_async(
519 + "monologue_end", self, loop_data=self.loop_data
520 + ) # type: ignore
521
516 - @extensible
522 + @extension.extensible
523 async def prepare_prompt(self, loop_data: LoopData) -> list[BaseMessage]:
524 self.context.log.set_progress("Building prompt")
525
526 # call extensions before setting prompts
521 - await self.call_extensions("message_loop_prompts_before", loop_data=loop_data)
527 + await extension.call_extensions_async(
528 + "message_loop_prompts_before", self, loop_data=loop_data
529 + )
530
531 # set system prompt and message history
532 loop_data.system = await self.get_system_prompt(self.loop_data)
533 loop_data.history_output = self.history.output()
534
535 # and allow extensions to edit them
528 - await self.call_extensions("message_loop_prompts_after", loop_data=loop_data)
536 + await extension.call_extensions_async(
537 + "message_loop_prompts_after", self, loop_data=loop_data
538 + )
539
540 # concatenate system prompt
541 system_text = "\n\n".join(loop_data.system)
@@ -565,7 +575,7 @@ class Agent:
575
576 return full_prompt
577
568 - @extensible
578 + @extension.extensible
579 async def handle_exception(self, location: str, exception: Exception):
580 if exception:
581 raise exception # exception handling is done by extensions
@@ -605,15 +615,15 @@ class Agent:
615
616 # raise HandledException(exception) # Re-raise the exception to kill the loop
617
608 - @extensible
618 + @extension.extensible
619 async def get_system_prompt(self, loop_data: LoopData) -> list[str]:
620 system_prompt: list[str] = []
611 - await self.call_extensions(
612 - "system_prompt", system_prompt=system_prompt, loop_data=loop_data
621 + await extension.call_extensions_async(
622 + "system_prompt", self, system_prompt=system_prompt, loop_data=loop_data
623 )
624 return system_prompt
625
616 - @extensible
626 + @extension.extensible
627 def parse_prompt(self, _prompt_file: str, **kwargs):
628 dirs = subagents.get_paths(self, "prompts")
629
@@ -622,7 +632,7 @@ class Agent:
632 )
633 return prompt
634
625 - @extensible
635 + @extension.extensible
636 def read_prompt(self, file: str, **kwargs) -> str:
637 dirs = subagents.get_paths(self, "prompts")
638
@@ -637,21 +647,21 @@ class Agent:
647 def set_data(self, field: str, value):
648 self.data[field] = value
649
640 - @extensible
650 + @extension.extensible
651 def hist_add_message(
652 self, ai: bool, content: history.MessageContent, tokens: int = 0
653 ):
654 self.last_message = datetime.now(timezone.utc)
655 # Allow extensions to process content before adding to history
656 content_data = {"content": content}
647 - asyncio.run(
648 - self.call_extensions("hist_add_before", content_data=content_data, ai=ai)
657 + extension.call_extensions_sync(
658 + "hist_add_before", self, content_data=content_data, ai=ai
659 )
660 return self.history.add_message(
661 ai=ai, content=content_data["content"], tokens=tokens
662 )
663
654 - @extensible
664 + @extension.extensible
665 def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
666 self.history.new_topic() # user message starts a new topic in history
667
@@ -680,25 +690,25 @@ class Agent:
690 self.last_user_message = msg
691 return msg
692
683 - @extensible
693 + @extension.extensible
694 def hist_add_ai_response(self, message: str):
695 self.loop_data.last_response = message
696 content = self.parse_prompt("fw.ai_response.md", message=message)
697 return self.hist_add_message(True, content=content)
698
689 - @extensible
699 + @extension.extensible
700 def hist_add_warning(self, message: history.MessageContent):
701 content = self.parse_prompt("fw.warning.md", message=message)
702 return self.hist_add_message(False, content=content)
703
694 - @extensible
704 + @extension.extensible
705 def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
706 data = {
707 "tool_name": tool_name,
708 "tool_result": tool_result,
709 **kwargs,
710 }
701 - asyncio.run(self.call_extensions("hist_add_tool_result", data=data))
711 + extension.call_extensions_sync("hist_add_tool_result", self, data=data)
712 return self.hist_add_message(False, content=data)
713
714 def concat_messages(
@@ -706,7 +716,7 @@ class Agent:
716 ): # TODO add param for message range, topic, history
717 return self.history.output_text(human_label="user", ai_label="assistant")
718
709 - @extensible
719 + @extension.extensible
720 def get_chat_model(self):
721 return models.get_chat_model(
722 self.config.chat_model.provider,
@@ -715,7 +725,7 @@ class Agent:
725 **self.config.chat_model.build_kwargs(),
726 )
727
718 - @extensible
728 + @extension.extensible
729 def get_utility_model(self):
730 return models.get_chat_model(
731 self.config.utility_model.provider,
@@ -724,7 +734,7 @@ class Agent:
734 **self.config.utility_model.build_kwargs(),
735 )
736
727 - @extensible
737 + @extension.extensible
738 def get_browser_model(self):
739 return models.get_browser_model(
740 self.config.browser_model.provider,
@@ -733,7 +743,7 @@ class Agent:
743 **self.config.browser_model.build_kwargs(),
744 )
745
736 - @extensible
746 + @extension.extensible
747 def get_embedding_model(self):
748 return models.get_embedding_model(
749 self.config.embeddings_model.provider,
@@ -742,7 +752,7 @@ class Agent:
752 **self.config.embeddings_model.build_kwargs(),
753 )
754
745 - @extensible
755 + @extension.extensible
756 async def call_utility_model(
757 self,
758 system: str,
@@ -760,7 +770,9 @@ class Agent:
770 "callback": callback,
771 "background": background,
772 }
763 - await self.call_extensions("util_model_call_before", call_data=call_data)
773 + await extension.call_extensions_async(
774 + "util_model_call_before", self, call_data=call_data
775 + )
776
777 # propagate stream to callback if set
778 async def stream_callback(chunk: str, total: str):
@@ -778,7 +790,7 @@ class Agent:
790
791 return response
792
781 - @extensible
793 + @extension.extensible
794 async def call_chat_model(
795 self,
796 messages: list[BaseMessage],
@@ -805,7 +817,7 @@ class Agent:
817
818 return response, reasoning
819
808 - @extensible
820 + @extension.extensible
821 async def rate_limiter_callback(
822 self, message: str, key: str, total: int, limit: int
823 ):
@@ -813,7 +825,7 @@ class Agent:
825 self.context.log.set_progress(message, True)
826 return False
827
816 - @extensible
828 + @extension.extensible
829 async def handle_intervention(self, progress: str = ""):
830 await self.wait_if_paused()
831 if (
@@ -838,7 +850,7 @@ class Agent:
850 while self.context.paused:
851 await asyncio.sleep(0.1)
852
841 - @extensible
853 + @extension.extensible
854 async def process_tools(self, msg: str):
855 # search for tool usage requests in agent message
856 tool_request = extract_tools.json_parse_dirty(msg)
@@ -894,8 +906,9 @@ class Agent:
906 await self.handle_intervention()
907
908 # Allow extensions to preprocess tool arguments
897 - await self.call_extensions(
909 + await extension.call_extensions_async(
910 "tool_execute_before",
911 + self,
912 tool_args=tool_args or {},
913 tool_name=tool_name,
914 )
@@ -904,8 +917,11 @@ class Agent:
917 await self.handle_intervention()
918
919 # Allow extensions to postprocess tool response
907 - await self.call_extensions(
908 - "tool_execute_after", response=response, tool_name=tool_name
920 + await extension.call_extensions_async(
921 + "tool_execute_after",
922 + self,
923 + response=response,
924 + tool_name=tool_name,
925 )
926
927 await tool.after_execution(response)
@@ -935,8 +951,9 @@ class Agent:
951
952 async def handle_reasoning_stream(self, stream: str):
953 await self.handle_intervention()
938 - await self.call_extensions(
954 + await extension.call_extensions_async(
955 "reasoning_stream",
956 + self,
957 loop_data=self.loop_data,
958 text=stream,
959 )
@@ -948,8 +965,9 @@ class Agent:
965 return # no reason to try
966 response = DirtyJson.parse_string(stream)
967 if isinstance(response, dict):
951 - await self.call_extensions(
968 + await extension.call_extensions_async(
969 "response_stream",
970 + self,
971 loop_data=self.loop_data,
972 text=stream,
973 parsed=response,
@@ -958,7 +976,7 @@ class Agent:
976 except Exception as e:
977 pass
978
961 - @extensible
979 + @extension.extensible
980 def get_tool(
981 self,
982 name: str,
@@ -992,9 +1010,4 @@ class Agent:
1010 message=message,
1011 loop_data=loop_data,
1012 **kwargs,
995 - )
996 -
997 - async def call_extensions(self, extension_point: str, **kwargs) -> Any:
998 - return await call_extensions(
999 - extension_point=extension_point, agent=self, **kwargs
1000 - )
1013 + )
\ No newline at end of file
api/banners.py
+2 -2
@@ -1,5 +1,5 @@
1 from helpers.api import ApiHandler, Request, Response
2 -from helpers.extension import call_extensions
2 +from helpers.extension import call_extensions_async
3
4
5 class GetBanners(ApiHandler):
@@ -13,7 +13,7 @@ class GetBanners(ApiHandler):
13 frontend_context = input.get("context", {})
14
15 # Banners array passed by reference - extensions append directly to it
16 - await call_extensions("banners", agent=None, banners=banners, frontend_context=frontend_context)
16 + await call_extensions_async("banners", agent=None, banners=banners, frontend_context=frontend_context)
17
18 return {"banners": banners}
19
api/message.py
+1 -1
@@ -58,7 +58,7 @@ class Message(ApiHandler):
58
59 # call extension point, alow it to modify data
60 data = { "message": message, "attachment_paths": attachment_paths }
61 - await extension.call_extensions("user_message_ui", agent=context.get_agent(), data=data)
61 + await extension.call_extensions_async("user_message_ui", agent=context.get_agent(), data=data)
62 message = data.get("message", "")
63 attachment_paths = data.get("attachment_paths", [])
64
extensions/python/agent_Agent_handle_exception_end/_50_handle_repairable_exception.py
+2 -3
@@ -3,10 +3,9 @@ from helpers.extension import Extension
3 from agent import LoopData
4 from helpers.localization import Localization
5 from helpers.errors import RepairableException
6 -from helpers import errors
6 +from helpers import errors, extension
7 from helpers.print_style import PrintStyle
8
9 -
9 class HandleRepairableException(Extension):
10 async def execute(self, data: dict = {}, **kwargs):
11 if not self.agent:
@@ -17,7 +16,7 @@ class HandleRepairableException(Extension):
16
17 if isinstance(data["exception"], RepairableException):
18 msg = {"message": errors.format_error(data["exception"])}
20 - await self.agent.call_extensions("error_format", msg=msg)
19 + await extension.call_extensions_async("error_format", agent=self.agent, msg=msg)
20 self.agent.hist_add_warning(msg["message"])
21 PrintStyle(font_color="red", padding=True).print(msg["message"])
22 self.agent.context.log.log(type="warning", content=msg["message"])
extensions/python/agent_init/_10_initial_message.py
+1 -1
@@ -5,7 +5,7 @@ from helpers.extension import Extension
5
6 class InitialMessage(Extension):
7
8 - async def execute(self, **kwargs):
8 + def execute(self, **kwargs):
9 """
10 Add an initial greeting message when first user message is processed.
11 Called only once per session via _process_chain method.
extensions/python/agent_init/_15_load_profile_settings.py
+1 -1
@@ -5,7 +5,7 @@ from helpers.extension import Extension
5
6 class LoadProfileSettings(Extension):
7
8 - async def execute(self, **kwargs) -> None:
8 + def execute(self, **kwargs) -> None:
9
10 if not self.agent or not self.agent.config.profile:
11 return
extensions/python/hist_add_before/_10_mask_content.py
+1 -1
@@ -4,7 +4,7 @@ from helpers.secrets import get_secrets_manager
4
5 class MaskHistoryContent(Extension):
6
7 - async def execute(self, **kwargs):
7 + def execute(self, **kwargs):
8 if not self.agent:
9 return
10
extensions/python/hist_add_tool_result/_90_save_tool_call_file.py
+1 -1
@@ -6,7 +6,7 @@ import os, re
6 LEN_MIN = 500
7
8 class SaveToolCallFile(Extension):
9 - async def execute(self, data: dict[str, Any] | None = None, **kwargs):
9 + def execute(self, data: dict[str, Any] | None = None, **kwargs):
10 if not self.agent:
11 return
12
helpers/extension.py
+125 -83
@@ -1,10 +1,9 @@
1 from abc import abstractmethod
2 -from typing import Any
2 +from typing import Any, Awaitable, Type, cast
3 from helpers import extract_tools, files
4 from helpers import cache, plugins, subagents
5 from typing import TYPE_CHECKING
6 from functools import wraps
7 -import asyncio
7 import inspect
8
9 if TYPE_CHECKING:
@@ -15,7 +14,7 @@ DEFAULT_EXTENSIONS_FOLDER = "python/extensions"
14 USER_EXTENSIONS_FOLDER = "usr/extensions"
15
16 _CACHE_AREA = "extension_folder_classes(extensions)(plugins)"
18 -cache.toggle_area(_CACHE_AREA, True) # cache off for now
17 +cache.toggle_area(_CACHE_AREA, False) # cache off for now
18
19
20 class _Unset:
@@ -35,62 +34,54 @@ def extensible(func):
34 - ``{func.__module__}_{func.__qualname__}_end`` with `.` replaced by `_`
35
36 When the wrapped function is called, the decorator builds a mutable ``data``
38 - payload and passes it to both extension points via ``call_extensions``:
37 + payload and passes it to both extension points:
38
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
39 + - ``data["args"]``: positional args (extensions may replace/mutate)
40 + - ``data["kwargs"]``: keyword args (extensions may replace/mutate)
41 + - ``data["result"]``: initialized to an internal sentinel; extensions may set
42 + this to short-circuit the wrapped function
43 - ``data["exception"]``: initialized to an internal sentinel; extensions may
44 set this to a ``BaseException`` instance to force-raise
45
46 + Sync functions call ``call_extensions_sync``. Async functions call
47 + ``call_extensions_async``.
48 +
49 Behavior:
50
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"]``.
51 + - ``-start`` extensions run first and may mutate inputs or set
52 + ``data["result"]`` / ``data["exception"]``.
53 + - If ``data["result"]`` is still unset, the decorator calls the wrapped
54 + function using the possibly modified ``data["args"]`` / ``data["kwargs"]``.
55 + - ``-end`` extensions run last and may rewrite ``data["result"]`` or replace /
56 + clear ``data["exception"]``.
57
58 Finally, if ``data["exception"]`` contains an exception it is raised;
59 otherwise ``data["result"]`` is returned.
60 """
61
62 - @wraps(func)
63 - async def _inner_async(*args, **kwargs):
62 + def _get_agent(args, kwargs):
63 from agent import Agent
64
66 - # prepare extension points data
65 + candidate = kwargs.get("agent")
66 + if isinstance(candidate, Agent) and bool(getattr(candidate, "__dict__", None)):
67 + return candidate
68 +
69 + for a in args:
70 + if isinstance(a, Agent) and bool(getattr(a, "__dict__", None)):
71 + return a
72 +
73 + return None
74 +
75 + def _prepare_inputs(args, kwargs):
76 module_name = getattr(func, "__module__", "").replace(".", "_")
77 qual_name = getattr(func, "__qualname__", "").replace(".", "_")
69 -
70 - # skip if extension point cannot be determined
78 if not module_name or not qual_name:
72 - return await func(*args, **kwargs)
79 + return None
80
81 start_point = f"{module_name}_{qual_name}_start"
82 end_point = f"{module_name}_{qual_name}_end"
83 + agent = _get_agent(args, kwargs)
84
77 - def _get_agent() -> "Agent|None":
78 - candidate = kwargs.get("agent")
79 - if isinstance(candidate, Agent) and bool(
80 - getattr(candidate, "__dict__", None)
81 - ):
82 - return candidate
83 -
84 - for a in args:
85 - if isinstance(a, Agent) and bool(getattr(a, "__dict__", None)):
86 - return a
87 -
88 - return None
89 -
90 - # try to find agent instance for better extension determination
91 - agent = _get_agent()
92 -
93 - # build extension data object - func input/output
85 data = {
86 "args": args,
87 "kwargs": kwargs,
@@ -98,44 +89,78 @@ def extensible(func):
89 "exception": None,
90 }
91
101 - # call start extensions, these can modify inputs, produce output or exception
102 - await call_extensions(start_point, agent=agent, data=data)
92 + return start_point, end_point, agent, data
93
104 - # if there is an explicit exception set, raise it
94 + def _process_result(data):
95 exc = data.get("exception")
96 if isinstance(exc, BaseException):
97 raise exc
98
109 - # if there is no result set, call the original function
110 - if data.get("result") is _UNSET:
99 + return data.get("result")
100 +
101 + def _call_original(data):
102 + call_args = data.get("args")
103 + call_kwargs = data.get("kwargs")
104 +
105 + if not isinstance(call_args, tuple):
106 + call_args = (call_args,)
107 + if not isinstance(call_kwargs, dict):
108 + call_kwargs = {}
109 +
110 + try:
111 + data["result"] = func(*call_args, **call_kwargs)
112 + except Exception as e:
113 + data["exception"] = e
114 + return _UNSET
115 +
116 + async def _run_async(*args, **kwargs):
117 + prepared = _prepare_inputs(args, kwargs)
118 + if prepared is None:
119 + return await func(*args, **kwargs)
120 +
121 + start_point, end_point, agent, data = prepared
122 +
123 + # call pre-extensions
124 + await call_extensions_async(start_point, agent=agent, data=data)
125 +
126 + # call the original if pre-extensions don't return a result
127 + if (result := _process_result(data)) is _UNSET:
128 + _call_original(data)
129 try:
112 - if inspect.iscoroutinefunction(func):
113 - data["result"] = await func(*args, **kwargs)
114 - else:
115 - data["result"] = func(*args, **kwargs)
130 + data["result"] = await data["result"]
131 except Exception as e:
132 data["exception"] = e
133
119 - # call end extensions, these can modify outputs or exception
120 - await call_extensions(end_point, agent=agent, data=data)
134 + # call post-extensions
135 + await call_extensions_async(end_point, agent=agent, data=data)
136
122 - # if there's an exception, raise it
123 - exc = data.get("exception")
124 - if isinstance(exc, BaseException):
125 - raise exc
137 + result = _process_result(data)
138 + return None if result is _UNSET else result
139
127 - # if there's a result, return it
128 - result = data.get("result")
140 + def _run_sync(*args, **kwargs):
141 + prepared = _prepare_inputs(args, kwargs)
142 + if prepared is None:
143 + return func(*args, **kwargs)
144 +
145 + start_point, end_point, agent, data = prepared
146 +
147 + # call pre-extensions
148 + call_extensions_sync(start_point, agent=agent, data=data)
149 +
150 + # call the original if pre-extensions don't return a result
151 + if (result := _process_result(data)) is _UNSET:
152 + _call_original(data)
153 +
154 + # call post-extensions
155 + call_extensions_sync(end_point, agent=agent, data=data)
156 +
157 + result = _process_result(data)
158 return None if result is _UNSET else result
159
160 if inspect.iscoroutinefunction(func):
132 - return _inner_async
133 -
134 - @wraps(func)
135 - def _inner_sync(*args, **kwargs):
136 - return asyncio.run(_inner_async(*args, **kwargs))
161 + return wraps(func)(_run_async)
162
138 - return _inner_sync
163 + return wraps(func)(_run_sync)
164
165
166 class Extension:
@@ -145,37 +170,34 @@ class Extension:
170 self.kwargs = kwargs
171
172 @abstractmethod
148 - async def execute(self, **kwargs) -> Any:
173 + def execute(self, **kwargs) -> None | Awaitable[None]:
174 pass
175
176
152 -async def call_extensions(
177 +async def call_extensions_async(
178 extension_point: str, agent: "Agent|None" = None, **kwargs
154 -) -> Any:
155 - # search for extension folders in all agent's paths
156 - paths = subagents.get_paths(agent, "extensions/python", extension_point)
179 +):
180 + # fetch classes for this extension point and agent
181 + classes = _get_extension_classes(extension_point, agent=agent, **kwargs)
182
158 - # # Add plugin backend extension paths (plugins/*/extensions/python/{extension_point})
159 - # plugin_paths = plugins.get_enabled_plugin_paths(
160 - # agent, "extensions", "python", extension_point
161 - # )
162 - # paths.extend(p for p in plugin_paths if p not in paths)
183 + # execute unique extensions
184 + for cls in classes:
185 + result = cls(agent=agent).execute(**kwargs)
186 + if isinstance(result, Awaitable):
187 + await result
188
164 - all_exts = [cls for path in paths for cls in _get_extensions(path)]
189
166 - # merge: first ocurrence of file name is the override
167 - unique = {}
168 - for cls in all_exts:
169 - file = _get_file_from_module(cls.__module__)
170 - if file not in unique:
171 - unique[file] = cls
172 - classes = sorted(
173 - unique.values(), key=lambda cls: _get_file_from_module(cls.__module__)
174 - )
190 +def call_extensions_sync(extension_point: str, agent: "Agent|None" = None, **kwargs):
191 + # fetch classes for this extension point and agent
192 + classes = _get_extension_classes(extension_point, agent=agent, **kwargs)
193
194 # execute unique extensions
195 for cls in classes:
178 - await cls(agent=agent).execute(**kwargs)
196 + result = cls(agent=agent).execute(**kwargs)
197 + if isinstance(result, Awaitable):
198 + raise ValueError(
199 + f"Extension {cls.__name__} returned awaitable in sync mode"
200 + )
201
202
203 def get_webui_extensions(
@@ -205,6 +227,26 @@ def get_webui_extensions(
227 return entries
228
229
230 +def _get_extension_classes(
231 + extension_point: str, agent: "Agent|None" = None, **kwargs
232 +) -> list[Type[Extension]]:
233 + # search for extension folders in all agent's paths
234 + paths = subagents.get_paths(agent, "extensions/python", extension_point)
235 +
236 + all_exts = [cls for path in paths for cls in _get_extensions(path)]
237 +
238 + # merge: first ocurrence of file name is the override
239 + unique = {}
240 + for cls in all_exts:
241 + file = _get_file_from_module(cls.__module__)
242 + if file not in unique:
243 + unique[file] = cls
244 + classes = sorted(
245 + unique.values(), key=lambda cls: _get_file_from_module(cls.__module__)
246 + )
247 + return classes
248 +
249 +
250 def _get_file_from_module(module_name: str) -> str:
251 return module_name.split(".")[-1]
252
helpers/settings.py
+2 -2
@@ -600,10 +600,10 @@ def _apply_settings(previous: Settings | None):
600 or _settings["embed_model_provider"] != previous["embed_model_provider"]
601 or _settings["embed_model_kwargs"] != previous["embed_model_kwargs"]
602 ):
603 - from helpers.extension import call_extensions
603 + from helpers.extension import call_extensions_async
604
605 defer.DeferredTask().start_task(
606 - call_extensions, "embedding_model_changed"
606 + call_extensions_async, "embedding_model_changed"
607 )
608
609 # update mcp settings if necessary
plugins/text_editor/tools/text_editor.py
+6 -6
@@ -1,5 +1,5 @@
1 from helpers.tool import Tool, Response
2 -from helpers.extension import call_extensions
2 +from helpers.extension import call_extensions_async
3 from helpers import plugins, runtime
4 from plugins.text_editor.helpers.file_ops import (
5 FileInfo,
@@ -63,7 +63,7 @@ class TextEditor(Tool):
63 "content": result["content"],
64 "warnings": result["warnings"],
65 }
66 - await call_extensions(
66 + await call_extensions_async(
67 "text_editor_read_after", agent=self.agent, data=ext_data
68 )
69
@@ -87,7 +87,7 @@ class TextEditor(Tool):
87
88 # Extension point
89 ext_data = {"path": path, "content": content}
90 - await call_extensions(
90 + await call_extensions_async(
91 "text_editor_write_before", agent=self.agent, data=ext_data
92 )
93
@@ -99,7 +99,7 @@ class TextEditor(Tool):
99 return self._error("write", path, result["error"])
100
101 # Extension point
102 - await call_extensions(
102 + await call_extensions_async(
103 "text_editor_write_after", agent=self.agent,
104 data={"path": path, "total_lines": result["total_lines"]},
105 )
@@ -148,7 +148,7 @@ class TextEditor(Tool):
148
149 # Extension point
150 ext_data = {"path": expanded, "edits": parsed}
151 - await call_extensions(
151 + await call_extensions_async(
152 "text_editor_patch_before", agent=self.agent, data=ext_data
153 )
154
@@ -160,7 +160,7 @@ class TextEditor(Tool):
160 return self._error("patch", path, str(exc))
161
162 # Extension point
163 - await call_extensions(
163 + await call_extensions_async(
164 "text_editor_patch_after", agent=self.agent,
165 data={"path": expanded, "total_lines": total_lines},
166 )
run_ui.py
+1 -1
@@ -15,7 +15,7 @@ from helpers import files, git, mcp_server, fasta2a_server, settings as settings
15 from helpers.files import get_abs_path
16 from helpers import runtime, dotenv, process
17 from helpers.websocket import WebSocketHandler, validate_ws_origin
18 -from helpers.api import register_api_route, requires_auth
18 +from helpers.api import register_api_route, requires_auth, csrf_protect
19 from helpers.print_style import PrintStyle
20 from helpers import login
21 import socketio # type: ignore[import-untyped]
webui/js/api.js
+11 -10
@@ -126,10 +126,10 @@ const CSRF_SLOW_WARN_MS = 1500;
126 export function getRuntimeId() {
127 if (runtimeIdCache) return runtimeIdCache;
128 const injected =
129 - window.runtimeInfo &&
130 - typeof window.runtimeInfo.id === "string" &&
131 - window.runtimeInfo.id.length > 0
132 - ? window.runtimeInfo.id
129 + globalThis.runtimeInfo &&
130 + typeof globalThis.runtimeInfo.id === "string" &&
131 + globalThis.runtimeInfo.id.length > 0
132 + ? globalThis.runtimeInfo.id
133 : null;
134 return injected;
135 }
@@ -167,6 +167,7 @@ export async function getCsrfToken() {
167 });
168 }
169
170 + /** @type {RequestInit} */
171 const fetchOptions = { credentials: "same-origin" };
172 if (controller) {
173 fetchOptions.signal = controller.signal;
@@ -177,7 +178,7 @@ export async function getCsrfToken() {
178 ? await Promise.race([fetchPromise, timeoutPromise])
179 : await fetchPromise;
180 } catch (error) {
180 - if (error && error.name === "AbortError") {
181 + if (error && error["name"] === "AbortError") {
182 throw new Error("CSRF token request timed out");
183 }
184 throw error;
@@ -204,10 +205,10 @@ export async function getCsrfToken() {
205 runtimeIdCache = runtimeId;
206 }
207 const injectedRuntimeId =
207 - window.runtimeInfo &&
208 - typeof window.runtimeInfo.id === "string" &&
209 - window.runtimeInfo.id.length > 0
210 - ? window.runtimeInfo.id
208 + globalThis.runtimeInfo &&
209 + typeof globalThis.runtimeInfo.id === "string" &&
210 + globalThis.runtimeInfo.id.length > 0
211 + ? globalThis.runtimeInfo.id
212 : null;
213 const cookieRuntimeId = runtimeId || injectedRuntimeId;
214 if (cookieRuntimeId) {
@@ -216,7 +217,7 @@ export async function getCsrfToken() {
217 console.warn("CSRF runtime id missing; skipping cookie name binding.");
218 }
219 const elapsedMs = Date.now() - startedAt;
219 - if (elapsedMs > CSRF_SLOW_WARN_MS && window.runtimeInfo?.isDevelopment) {
220 + if (elapsedMs > CSRF_SLOW_WARN_MS && globalThis.runtimeInfo?.isDevelopment) {
221 console.warn(`CSRF token request took ${elapsedMs}ms`);
222 }
223 return csrfToken;
webui/js/confirmClick.js
+1 -1
@@ -68,7 +68,7 @@ function resetButton(button, state) {
68 // Register Alpine magic helper
69 export function registerAlpineMagic() {
70 if (globalThis.Alpine) {
71 - Alpine.magic('confirmClick', () => confirmClick);
71 + globalThis.Alpine.magic('confirmClick', () => confirmClick);
72 }
73 }
74