From 034aa5deb8227495d4e12e79cb79be3de4901351 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Fri, 31 Jul 2026 20:45:14 +0530 Subject: [PATCH] feat(crew): add on_event hook so overrides can't silence the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutputCrewNode.process_event carried the framework routing (LLM request -> generate_response, transcript -> context, settings), so a subclass that overrode process_event without super() silently dropped it — the agent stopped responding (0 turns). Extract that routing into a private _route_framework_event() and add an on_event() hook as the documented, super()-free extension point. process_event now runs: interrupt -> framework routing -> on_event -> forward. Overriding on_event can't drop framework routing; overriding process_event is discouraged in the docstring. Legacy process_event-with-super() overrides still work. Backward-compatible, scoped to OutputCrewNode (RootNode/SinkNode/Background legitimately override process_event without super and are untouched). Tests cover the on_event path and the legacy super() path. --- .../atoms/crew/nodes/output_crew.py | 28 ++++++--- .../custom/test_output_node_on_event_hook.py | 63 +++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 tests/custom/test_output_node_on_event_hook.py diff --git a/src/smallestai/atoms/crew/nodes/output_crew.py b/src/smallestai/atoms/crew/nodes/output_crew.py index 0f4b860d..bfb86dab 100644 --- a/src/smallestai/atoms/crew/nodes/output_crew.py +++ b/src/smallestai/atoms/crew/nodes/output_crew.py @@ -75,16 +75,10 @@ async def _update_settings(self, settings: Dict[str, Any]): logger.debug(f"[{self.name}] Updating setting {key} to {value}") setattr(self.settings, key, value) - async def process_event(self, event: SDKEvent): - """ - Route events to appropriate handlers. - - Handles: - - LLMRequestEvent -> Start generation - """ - - await super().process_event(event) - + async def _route_framework_event(self, event: SDKEvent): + """Framework-owned event routing. Always runs, independent of any user + `on_event` override, so a subclass can't accidentally silence the agent + (e.g. by dropping the LLM-request -> generate_response path).""" if isinstance(event, SDKSystemLLMRequestEvent): await self._handle_llm_request() elif isinstance(event, SDKAgentTranscriptUpdateEvent): @@ -92,6 +86,20 @@ async def process_event(self, event: SDKEvent): elif isinstance(event, SDKSystemUpdateOutputAgentSettingsEvent): await self._update_settings(event.settings) + async def on_event(self, event: SDKEvent): + """Override this to react to events. This is the safe extension point: + the framework routing (interrupts, LLM requests, transcript/context and + settings updates) already ran before this is called, so you do NOT need + to call super(). Prefer this over overriding `process_event`, which + carries that framework routing — dropping it silences the agent.""" + pass + + async def process_event(self, event: SDKEvent): + """Framework dispatch: interrupt handling, output-node routing, the user + `on_event` hook, then forward. Prefer overriding `on_event` over this.""" + await super().process_event(event) + await self._route_framework_event(event) + await self.on_event(event) await self.send_event(event) async def speak(self, text: str): diff --git a/tests/custom/test_output_node_on_event_hook.py b/tests/custom/test_output_node_on_event_hook.py new file mode 100644 index 00000000..fa077ce3 --- /dev/null +++ b/tests/custom/test_output_node_on_event_hook.py @@ -0,0 +1,63 @@ +"""OutputCrewNode: framework routing must run even when a subclass overrides the +user hook without super(). Overriding `on_event` (the documented extension point) +must NOT silence the LLM-request -> generate_response path, and existing +`process_event`-with-super() overrides must keep working (backward compat).""" +import unittest +from unittest import mock + +from smallestai.atoms.crew.nodes import OutputCrewNode +from smallestai.atoms.crew.events import SDKSystemLLMRequestEvent + + +class _OnEventAgent(OutputCrewNode): + """Recommended pattern: override on_event, no super().""" + def __init__(self, order): + super().__init__(name="a") + self._order = order + + async def on_event(self, event): + self._order.append("on_event") + + async def generate_response(self): + if False: + yield "" + + +class _LegacyProcessEventAgent(OutputCrewNode): + """Legacy pattern: override process_event and call super().""" + def __init__(self, order): + super().__init__(name="b") + self._order = order + + async def process_event(self, event): + await super().process_event(event) + self._order.append("legacy_process_event") + + async def generate_response(self): + if False: + yield "" + + +class OnEventHookTest(unittest.IsolatedAsyncioTestCase): + async def test_on_event_override_keeps_framework_routing(self): + order = [] + a = _OnEventAgent(order) + a._handle_llm_request = mock.AsyncMock(side_effect=lambda: order.append("llm")) + a.send_event = mock.AsyncMock() + await a.process_event(SDKSystemLLMRequestEvent()) + self.assertIn("llm", order, "framework LLM routing must run") + self.assertIn("on_event", order, "user hook must run") + self.assertLess(order.index("llm"), order.index("on_event"), "framework runs before user hook") + + async def test_legacy_process_event_with_super_still_routes(self): + order = [] + b = _LegacyProcessEventAgent(order) + b._handle_llm_request = mock.AsyncMock(side_effect=lambda: order.append("llm")) + b.send_event = mock.AsyncMock() + await b.process_event(SDKSystemLLMRequestEvent()) + self.assertIn("llm", order, "backward-compat: super() still routes") + self.assertIn("legacy_process_event", order) + + +if __name__ == "__main__": + unittest.main()