From d353316e385fa6f245a77ace058c60a5d1c70509 Mon Sep 17 00:00:00 2001 From: Wanbogang Date: Sat, 28 Feb 2026 18:59:38 +0700 Subject: [PATCH] fix(orchestrator): add exception handling in _promise_action and flush_promises - Wrap input_type() construction in try/except to handle invalid enum values or missing parameters from LLM output - Wrap connector.connect() in try/except to handle hardware/network failures - Retrieve and log exceptions from completed tasks in flush_promises() - Add TestActionOrchestratorExceptionHandling with 4 tests covering: connector crash, invalid enum value, partial failure, and missing required parameter scenarios --- src/actions/orchestrator.py | 21 ++++- tests/actions/test_orchestrator.py | 121 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/actions/orchestrator.py b/src/actions/orchestrator.py index 92329b2a4..04337a10b 100644 --- a/src/actions/orchestrator.py +++ b/src/actions/orchestrator.py @@ -125,6 +125,13 @@ async def flush_promises(self) -> tuple[list[T.Any], list[asyncio.Task[T.Any]]]: self.promise_queue = [] + for task in done: + if task.exception() is not None: + logging.exception( + "A promised action task failed", + exc_info=task.exception(), + ) + return list(done), list(pending) async def promise(self, actions: list[Action]) -> None: @@ -376,9 +383,19 @@ async def _promise_action(self, agent_action: AgentAction, action: Action) -> T. f"Parameter '{key}' not found in input type hints for action '{agent_action.llm_label}'" ) - input_interface = input_type(**converted_params) + try: + input_interface = input_type(**converted_params) + except Exception: + logging.exception( + f"Failed to create input interface for action '{agent_action.llm_label}'" + ) + return None - await agent_action.connector.connect(input_interface) + try: + await agent_action.connector.connect(input_interface) + except Exception: + logging.exception(f"Connector failed for action '{agent_action.llm_label}'") + return None return input_interface diff --git a/tests/actions/test_orchestrator.py b/tests/actions/test_orchestrator.py index 9c086d041..45eb105c4 100644 --- a/tests/actions/test_orchestrator.py +++ b/tests/actions/test_orchestrator.py @@ -913,3 +913,124 @@ class NestedInput: await orchestrator.flush_promises() assert len(MockConnector.execution_order) == 1 + + +class TestActionOrchestratorExceptionHandling: + """Test exception handling in _promise_action and flush_promises.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Reset mock connector state before each test.""" + MockConnector.reset() + + @pytest.mark.asyncio + async def test_connector_crash_does_not_propagate( + self, mock_runtime_config, create_agent_action + ): + """Test that a crashing connector does not crash the orchestrator.""" + action = create_agent_action("move", "move") + action.connector.connect = lambda _: (_ for _ in ()).throw( + RuntimeError("hardware disconnect") + ) + mock_runtime_config.agent_actions = [action] + orchestrator = ActionOrchestrator(mock_runtime_config) + + actions = [Action(type="move", value="forward")] + await orchestrator.promise(actions) + done, pending = await orchestrator.flush_promises() + + assert len(done) == 1 + assert len(pending) == 0 + + @pytest.mark.asyncio + async def test_invalid_enum_value_does_not_crash(self, mock_runtime_config): + """Test that an invalid enum value from LLM does not crash the orchestrator.""" + from enum import Enum + + class MovementAction(Enum): + FORWARD = "forward" + BACKWARD = "backward" + + @dataclass + class EnumInput: + action: MovementAction + + @dataclass + class EnumInterface(Interface[EnumInput, MockOutput]): + input: EnumInput + output: MockOutput + + connector = MockConnector(ActionConfig(), "move") + agent_action = AgentAction( + name="move", + llm_label="move", + interface=EnumInterface, + connector=connector, + exclude_from_prompt=False, + ) + mock_runtime_config.agent_actions = [agent_action] + orchestrator = ActionOrchestrator(mock_runtime_config) + + # "fly" is not a valid MovementAction — simulates bad LLM output + actions = [Action(type="move", value="fly")] + await orchestrator.promise(actions) + done, pending = await orchestrator.flush_promises() + + assert len(done) == 1 + assert len(pending) == 0 + + @pytest.mark.asyncio + async def test_other_actions_continue_after_one_crash( + self, mock_runtime_config, create_agent_action + ): + """Test that other actions still execute even if one connector crashes.""" + good_action = create_agent_action("speak", "speak") + bad_action = create_agent_action("move", "move") + bad_action.connector.connect = lambda _: (_ for _ in ()).throw( + RuntimeError("motor failure") + ) + + mock_runtime_config.agent_actions = [good_action, bad_action] + orchestrator = ActionOrchestrator(mock_runtime_config) + + actions = [ + Action(type="speak", value="hello"), + Action(type="move", value="forward"), + ] + await orchestrator.promise(actions) + await orchestrator.flush_promises() + + assert "speak" in MockConnector.execution_order + + @pytest.mark.asyncio + async def test_missing_required_param_does_not_crash(self, mock_runtime_config): + """Test that missing required parameter in input_type() does not crash orchestrator.""" + + @dataclass + class StrictInput: + required_param: str + another_required: int + + @dataclass + class StrictInterface(Interface[StrictInput, MockOutput]): + input: StrictInput + output: MockOutput + + connector = MockConnector(ActionConfig(), "move") + agent_action = AgentAction( + name="move", + llm_label="move", + interface=StrictInterface, + connector=connector, + exclude_from_prompt=False, + ) + mock_runtime_config.agent_actions = [agent_action] + orchestrator = ActionOrchestrator(mock_runtime_config) + + # value tidak mengandung required_param maupun another_required + actions = [Action(type="move", value="forward")] + await orchestrator.promise(actions) + done, pending = await orchestrator.flush_promises() + + assert len(done) == 1 + assert len(pending) == 0