diff --git a/frontend/__tests__/components/chat/chat-input.test.tsx b/frontend/__tests__/components/chat/chat-input.test.tsx index 299609aada..2e65f27bf5 100644 --- a/frontend/__tests__/components/chat/chat-input.test.tsx +++ b/frontend/__tests__/components/chat/chat-input.test.tsx @@ -223,7 +223,7 @@ describe("ChatInput", () => { render(); const textarea = screen.getByRole("textbox"); expect(textarea).toBeInTheDocument(); - + // The actual verification of maxRows=16 is handled internally by the TextareaAutosize component // and affects how many rows the textarea can expand to }); diff --git a/frontend/__tests__/services/observations.test.ts b/frontend/__tests__/services/observations.test.ts index a468005ded..6f31a4c86b 100644 --- a/frontend/__tests__/services/observations.test.ts +++ b/frontend/__tests__/services/observations.test.ts @@ -48,4 +48,4 @@ describe("Observations Service", () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index f16abc307b..784c5b0da3 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -1,8 +1,9 @@ import asyncio import copy import os +import time import traceback -from typing import Callable, ClassVar, Type +from typing import Callable, ClassVar, Tuple, Type import litellm # noqa from litellm.exceptions import ( # noqa @@ -85,7 +86,7 @@ class AgentController: agent_configs: dict[str, AgentConfig] parent: 'AgentController | None' = None delegate: 'AgentController | None' = None - _pending_action: Action | None = None + _pending_action_info: Tuple[Action, float] | None = None # (action, timestamp) _closed: bool = False filter_out: ClassVar[tuple[type[Event], ...]] = ( NullAction, @@ -407,8 +408,21 @@ class AgentController: elif isinstance(event, Observation): await self._handle_observation(event) - if self.should_step(event): + should_step = self.should_step(event) + if should_step: + self.log( + 'info', + f'Stepping agent after event: {type(event).__name__}', + extra={'msg_type': 'STEPPING_AGENT'}, + ) self.step() + elif isinstance(event, MessageAction) and event.source == EventSource.USER: + # If we received a user message but aren't stepping, log why + self.log( + 'warning', + f'Not stepping agent after user message. Current state: {self.get_agent_state()}', + extra={'msg_type': 'NOT_STEPPING_AFTER_USER_MESSAGE'}, + ) async def _handle_action(self, action: Action) -> None: """Handles an Action from the agent or delegate.""" @@ -461,7 +475,9 @@ class AgentController: if self._pending_action and self._pending_action.id == observation.cause: if self.state.agent_state == AgentState.AWAITING_USER_CONFIRMATION: return + self._pending_action = None + if self.state.agent_state == AgentState.USER_CONFIRMED: await self.set_agent_state_to(AgentState.RUNNING) if self.state.agent_state == AgentState.USER_REJECTED: @@ -742,9 +758,21 @@ class AgentController: async def _step(self) -> None: """Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget.""" if self.get_agent_state() != AgentState.RUNNING: + self.log( + 'info', + f'Agent not stepping because state is {self.get_agent_state()} (not RUNNING)', + extra={'msg_type': 'STEP_BLOCKED_STATE'}, + ) return if self._pending_action: + action_id = getattr(self._pending_action, 'id', 'unknown') + action_type = type(self._pending_action).__name__ + self.log( + 'info', + f'Agent not stepping because of pending action: {action_type} (id={action_id})', + extra={'msg_type': 'STEP_BLOCKED_PENDING_ACTION'}, + ) return self.log( @@ -897,6 +925,61 @@ class AgentController: stop_step = True return stop_step + @property + def _pending_action(self) -> Action | None: + """Get the current pending action with time tracking. + + Returns: + Action | None: The current pending action, or None if there isn't one. + """ + if self._pending_action_info is None: + return None + + action, timestamp = self._pending_action_info + current_time = time.time() + elapsed_time = current_time - timestamp + + # Log if the pending action has been active for a long time (but don't clear it) + if elapsed_time > 60.0: # 1 minute - just for logging purposes + action_id = getattr(action, 'id', 'unknown') + action_type = type(action).__name__ + self.log( + 'warning', + f'Pending action active for {elapsed_time:.2f}s: {action_type} (id={action_id})', + extra={'msg_type': 'PENDING_ACTION_TIMEOUT'}, + ) + + return action + + @_pending_action.setter + def _pending_action(self, action: Action | None) -> None: + """Set or clear the pending action with timestamp and logging. + + Args: + action: The action to set as pending, or None to clear. + """ + if action is None: + if self._pending_action_info is not None: + prev_action, timestamp = self._pending_action_info + action_id = getattr(prev_action, 'id', 'unknown') + action_type = type(prev_action).__name__ + elapsed_time = time.time() - timestamp + self.log( + 'info', + f'Cleared pending action after {elapsed_time:.2f}s: {action_type} (id={action_id})', + extra={'msg_type': 'PENDING_ACTION_CLEARED'}, + ) + self._pending_action_info = None + else: + action_id = getattr(action, 'id', 'unknown') + action_type = type(action).__name__ + self.log( + 'info', + f'Set pending action: {action_type} (id={action_id})', + extra={'msg_type': 'PENDING_ACTION_SET'}, + ) + self._pending_action_info = (action, time.time()) + def get_state(self) -> State: """Returns the current running state object. @@ -1181,13 +1264,26 @@ class AgentController: ) def __repr__(self): + pending_action_info = '' + if ( + hasattr(self, '_pending_action_info') + and self._pending_action_info is not None + ): + action, timestamp = self._pending_action_info + action_id = getattr(action, 'id', 'unknown') + action_type = type(action).__name__ + elapsed_time = time.time() - timestamp + pending_action_info = ( + f'{action_type}(id={action_id}, elapsed={elapsed_time:.2f}s)' + ) + return ( f'AgentController(id={getattr(self, "id", "")}, ' f'agent={getattr(self, "agent", "")!r}, ' f'event_stream={getattr(self, "event_stream", "")!r}, ' f'state={getattr(self, "state", "")!r}, ' f'delegate={getattr(self, "delegate", "")!r}, ' - f'_pending_action={getattr(self, "_pending_action", "")!r})' + f'_pending_action={pending_action_info})' ) def _is_awaiting_observation(self): diff --git a/pyproject.toml b/pyproject.toml index c5f13ef3b5..fda6adcc06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ gevent = "^24.2.1" [tool.coverage.run] concurrency = ["gevent"] + [tool.poetry.group.runtime.dependencies] jupyterlab = "*" notebook = "*" @@ -125,6 +126,7 @@ ignore = ["D1"] [tool.ruff.lint.pydocstyle] convention = "google" + [tool.poetry.group.evaluation.dependencies] streamlit = "*" whatthepatch = "*"