diff --git a/openhands/core/schema/observation.py b/openhands/core/schema/observation.py index 1c6ef55bac..89cd6183d3 100644 --- a/openhands/core/schema/observation.py +++ b/openhands/core/schema/observation.py @@ -49,8 +49,8 @@ class ObservationTypeSchema(BaseModel): CONDENSE: str = Field(default='condense') """Result of a condensation operation.""" - MICROAGENT: str = Field(default='microagent') - """Result of a microagent retrieval operation.""" + RECALL: str = Field(default='recall') + """Result of a recall operation. This can be the workspace context, a microagent, or other types of information.""" ObservationType = ObservationTypeSchema() diff --git a/openhands/events/observation/__init__.py b/openhands/events/observation/__init__.py index 9e9fdf6568..9ca577c300 100644 --- a/openhands/events/observation/__init__.py +++ b/openhands/events/observation/__init__.py @@ -3,7 +3,7 @@ from openhands.events.observation.agent import ( AgentCondensationObservation, AgentStateChangedObservation, AgentThinkObservation, - MicroagentObservation, + RecallObservation, ) from openhands.events.observation.browse import BrowserOutputObservation from openhands.events.observation.commands import ( @@ -42,6 +42,6 @@ __all__ = [ 'SuccessObservation', 'UserRejectObservation', 'AgentCondensationObservation', - 'MicroagentObservation', + 'RecallObservation', 'RecallType', ] diff --git a/openhands/events/observation/agent.py b/openhands/events/observation/agent.py index e2dfe49456..2e2831283d 100644 --- a/openhands/events/observation/agent.py +++ b/openhands/events/observation/agent.py @@ -60,13 +60,13 @@ class MicroagentKnowledge: @dataclass -class MicroagentObservation(Observation): +class RecallObservation(Observation): """The retrieval of content from a microagent or more microagents.""" recall_type: RecallType - observation: str = ObservationType.MICROAGENT + observation: str = ObservationType.RECALL - # environment + # workspace context repo_name: str = '' repo_directory: str = '' repo_instructions: str = '' @@ -95,22 +95,36 @@ class MicroagentObservation(Observation): @property def message(self) -> str: - return self.__str__() + return ( + 'Added workspace context' + if self.recall_type == RecallType.WORKSPACE_CONTEXT + else 'Added microagent knowledge' + ) def __str__(self) -> str: - # Build a string representation of all fields - fields = [ - f'recall_type={self.recall_type}', - f'repo_name={self.repo_name}', - f'repo_instructions={self.repo_instructions[:20]}...', - f'runtime_hosts={self.runtime_hosts}', - f'additional_agent_instructions={self.additional_agent_instructions[:20]}...', - ] - - # Only include microagent_knowledge if it's not empty + # Build a string representation + fields = [] + if self.recall_type == RecallType.WORKSPACE_CONTEXT: + fields.extend( + [ + f'recall_type={self.recall_type}', + f'repo_name={self.repo_name}', + f'repo_instructions={self.repo_instructions[:20]}...', + f'runtime_hosts={self.runtime_hosts}', + f'additional_agent_instructions={self.additional_agent_instructions[:20]}...', + ] + ) + else: + fields.extend( + [ + f'recall_type={self.recall_type}', + ] + ) if self.microagent_knowledge: - fields.append( - f'microagent_knowledge={", ".join([m.name for m in self.microagent_knowledge])}' + fields.extend( + [ + f'microagent_knowledge={", ".join([m.name for m in self.microagent_knowledge])}', + ] ) - return f'**MicroagentObservation**\n{", ".join(fields)}' + return f'**RecallObservation**\n{", ".join(fields)}' diff --git a/openhands/events/serialization/event.py b/openhands/events/serialization/event.py index 8c096e9848..2136bee790 100644 --- a/openhands/events/serialization/event.py +++ b/openhands/events/serialization/event.py @@ -122,7 +122,7 @@ def event_to_dict(event: 'Event') -> dict: # props is a dict whose values can include a complex object like an instance of a BaseModel subclass # such as CmdOutputMetadata # we serialize it along with the rest - # we also handle the Enum conversion for MicroagentObservation + # we also handle the Enum conversion for RecallObservation d['extras'] = { k: (v.value if isinstance(v, Enum) else _convert_pydantic_to_dict(v)) for k, v in props.items() diff --git a/openhands/events/serialization/observation.py b/openhands/events/serialization/observation.py index e1d59833bc..84bb20d4a2 100644 --- a/openhands/events/serialization/observation.py +++ b/openhands/events/serialization/observation.py @@ -6,7 +6,7 @@ from openhands.events.observation.agent import ( AgentStateChangedObservation, AgentThinkObservation, MicroagentKnowledge, - MicroagentObservation, + RecallObservation, ) from openhands.events.observation.browse import BrowserOutputObservation from openhands.events.observation.commands import ( @@ -43,7 +43,7 @@ observations = ( UserRejectObservation, AgentCondensationObservation, AgentThinkObservation, - MicroagentObservation, + RecallObservation, ) OBSERVATION_TYPE_TO_CLASS = { @@ -114,7 +114,7 @@ def observation_from_dict(observation: dict) -> Observation: else: extras['metadata'] = CmdOutputMetadata() - if observation_class is MicroagentObservation: + if observation_class is RecallObservation: # handle the Enum conversion if 'recall_type' in extras: extras['recall_type'] = RecallType(extras['recall_type']) diff --git a/openhands/memory/conversation_memory.py b/openhands/memory/conversation_memory.py index 3c43116e50..596a76e14e 100644 --- a/openhands/memory/conversation_memory.py +++ b/openhands/memory/conversation_memory.py @@ -31,7 +31,7 @@ from openhands.events.observation import ( ) from openhands.events.observation.agent import ( MicroagentKnowledge, - MicroagentObservation, + RecallObservation, ) from openhands.events.observation.error import ErrorObservation from openhands.events.observation.observation import Observation @@ -386,19 +386,19 @@ class ConversationMemory: text = truncate_content(obs.content, max_message_chars) message = Message(role='user', content=[TextContent(text=text)]) elif ( - isinstance(obs, MicroagentObservation) + isinstance(obs, RecallObservation) and self.agent_config.enable_prompt_extensions ): if obs.recall_type == RecallType.WORKSPACE_CONTEXT: # everything is optional, check if they are present - repo_info = ( - RepositoryInfo( + if obs.repo_name or obs.repo_directory: + repo_info = RepositoryInfo( repo_name=obs.repo_name or '', repo_directory=obs.repo_directory or '', ) - if obs.repo_name or obs.repo_directory - else None - ) + else: + repo_info = None + if obs.runtime_hosts or obs.additional_agent_instructions: runtime_info = RuntimeInfo( available_hosts=obs.runtime_hosts, @@ -421,22 +421,49 @@ class ConversationMemory: ) has_repo_instructions = bool(repo_instructions.strip()) - # Build additional info if we have something to render + # Filter and process microagent knowledge + filtered_agents = [] + if obs.microagent_knowledge: + # Exclude disabled microagents + filtered_agents = [ + agent + for agent in obs.microagent_knowledge + if agent.name not in self.agent_config.disabled_microagents + ] + + has_microagent_knowledge = bool(filtered_agents) + + # Generate appropriate content based on what is present + message_content = [] + + # Build the workspace context information if has_repo_info or has_runtime_info or has_repo_instructions: - # ok, now we can build the additional info - formatted_text = self.prompt_manager.build_additional_info( - repository_info=repo_info, - runtime_info=runtime_info, - repo_instructions=repo_instructions, + formatted_workspace_text = ( + self.prompt_manager.build_workspace_context( + repository_info=repo_info, + runtime_info=runtime_info, + repo_instructions=repo_instructions, + ) ) - message = Message( - role='user', content=[TextContent(text=formatted_text)] + message_content.append(TextContent(text=formatted_workspace_text)) + + # Add microagent knowledge if present + if has_microagent_knowledge: + formatted_microagent_text = ( + self.prompt_manager.build_microagent_info( + triggered_agents=filtered_agents, + ) ) + message_content.append(TextContent(text=formatted_microagent_text)) + + # Return the combined message if we have any content + if message_content: + message = Message(role='user', content=message_content) else: return [] elif obs.recall_type == RecallType.KNOWLEDGE: # Use prompt manager to build the microagent info - # First, filter out agents that appear in earlier MicroagentObservations + # First, filter out agents that appear in earlier RecallObservations filtered_agents = self._filter_agents_in_microagent_obs( obs, current_index, events or [] ) @@ -465,7 +492,7 @@ class ConversationMemory: # Return empty list if no microagents to include or all were disabled return [] elif ( - isinstance(obs, MicroagentObservation) + isinstance(obs, RecallObservation) and not self.agent_config.enable_prompt_extensions ): # If prompt extensions are disabled, we don't add any additional info @@ -505,12 +532,12 @@ class ConversationMemory: break def _filter_agents_in_microagent_obs( - self, obs: MicroagentObservation, current_index: int, events: list[Event] + self, obs: RecallObservation, current_index: int, events: list[Event] ) -> list[MicroagentKnowledge]: - """Filter out agents that appear in earlier MicroagentObservations. + """Filter out agents that appear in earlier RecallObservations. Args: - obs: The current MicroagentObservation to filter + obs: The current RecallObservation to filter current_index: The index of the current event in the events list events: The list of all events @@ -533,7 +560,7 @@ class ConversationMemory: def _has_agent_in_earlier_events( self, agent_name: str, current_index: int, events: list[Event] ) -> bool: - """Check if an agent appears in any earlier MicroagentObservation in the event list. + """Check if an agent appears in any earlier RecallObservation in the event list. Args: agent_name: The name of the agent to look for @@ -541,13 +568,11 @@ class ConversationMemory: events: The list of all events Returns: - bool: True if the agent appears in an earlier MicroagentObservation, False otherwise + bool: True if the agent appears in an earlier RecallObservation, False otherwise """ for event in events[:current_index]: - if ( - isinstance(event, MicroagentObservation) - and event.recall_type == RecallType.KNOWLEDGE - ): + # Note that this check includes the WORKSPACE_CONTEXT + if isinstance(event, RecallObservation): if any( agent.name == agent_name for agent in event.microagent_knowledge ): diff --git a/openhands/memory/memory.py b/openhands/memory/memory.py index 2dba5f10ea..a6eab63d06 100644 --- a/openhands/memory/memory.py +++ b/openhands/memory/memory.py @@ -9,7 +9,7 @@ from openhands.events.action.agent import RecallAction from openhands.events.event import Event, EventSource, RecallType from openhands.events.observation.agent import ( MicroagentKnowledge, - MicroagentObservation, + RecallObservation, ) from openhands.events.observation.empty import NullObservation from openhands.events.stream import EventStream, EventStreamSubscriber @@ -31,7 +31,7 @@ GLOBAL_MICROAGENTS_DIR = os.path.join( class Memory: """ Memory is a component that listens to the EventStream for information retrieval actions - (a RecallAction) and publishes observations with the content (such as MicroagentObservation). + (a RecallAction) and publishes observations with the content (such as RecallObservation). """ sid: str @@ -75,48 +75,59 @@ class Memory: async def _on_event(self, event: Event): """Handle an event from the event stream asynchronously.""" try: - observation: MicroagentObservation | NullObservation | None = None - if isinstance(event, RecallAction): # if this is a workspace context recall (on first user message) - # create and add a MicroagentObservation - # with info about repo and runtime. + # create and add a RecallObservation + # with info about repo, runtime, instructions, etc. including microagent knowledge if any if ( event.source == EventSource.USER and event.recall_type == RecallType.WORKSPACE_CONTEXT ): - observation = self._on_first_microagent_action(event) + logger.debug('Workspace context recall') + workspace_obs: RecallObservation | NullObservation | None = None - # continue with the next handler, to include knowledge microagents if suitable for this query - assert observation is None or isinstance( - observation, MicroagentObservation - ), f'Expected a MicroagentObservation, but got {type(observation)}' - observation = self._on_microagent_action( - event, prev_observation=observation - ) + workspace_obs = self._on_workspace_context_recall(event) + if workspace_obs is None: + workspace_obs = NullObservation(content='') - if observation is None: - observation = NullObservation(content='') + # important: this will release the execution flow from waiting for the retrieval to complete + workspace_obs._cause = event.id # type: ignore[union-attr] - # important: this will release the execution flow from waiting for the retrieval to complete - observation._cause = event.id # type: ignore[union-attr] + self.event_stream.add_event(workspace_obs, EventSource.ENVIRONMENT) + return - self.event_stream.add_event(observation, EventSource.ENVIRONMENT) + # Handle knowledge recall (triggered microagents) + elif ( + event.source == EventSource.USER + and event.recall_type == RecallType.KNOWLEDGE + ): + logger.debug('Microagent knowledge recall') + microagent_obs: RecallObservation | NullObservation | None = None + microagent_obs = self._on_microagent_recall(event) + if microagent_obs is None: + microagent_obs = NullObservation(content='') + + # important: this will release the execution flow from waiting for the retrieval to complete + microagent_obs._cause = event.id # type: ignore[union-attr] + + self.event_stream.add_event(microagent_obs, EventSource.ENVIRONMENT) + return except Exception as e: error_str = f'Error: {str(e.__class__.__name__)}' logger.error(error_str) self.send_error_message('STATUS$ERROR_MEMORY', error_str) return - def _on_first_microagent_action( + def _on_workspace_context_recall( self, event: RecallAction - ) -> MicroagentObservation | None: - """Add repository and runtime information to the stream as a MicroagentObservation.""" + ) -> RecallObservation | None: + """Add repository and runtime information to the stream as a RecallObservation.""" - # Create ENVIRONMENT info: + # Create WORKSPACE_CONTEXT info: # - repository_info # - runtime_info # - repository_instructions + # - microagent_knowledge # Collect raw repository instructions repo_instructions = '' @@ -130,9 +141,17 @@ class Memory: repo_instructions += '\n\n' repo_instructions += microagent.content + # Find any matched microagents based on the query + microagent_knowledge = self._find_microagent_knowledge(event.query) + # Create observation if we have anything - if self.repository_info or self.runtime_info or repo_instructions: - obs = MicroagentObservation( + if ( + self.repository_info + or self.runtime_info + or repo_instructions + or microagent_knowledge + ): + obs = RecallObservation( recall_type=RecallType.WORKSPACE_CONTEXT, repo_name=self.repository_info.repo_name if self.repository_info and self.repository_info.repo_name is not None @@ -149,29 +168,47 @@ class Memory: if self.runtime_info and self.runtime_info.additional_agent_instructions is not None else '', - microagent_knowledge=[], - content='Retrieved environment info', + microagent_knowledge=microagent_knowledge, + content='Added workspace context', ) return obs return None - def _on_microagent_action( + def _on_microagent_recall( self, event: RecallAction, - prev_observation: MicroagentObservation | None = None, - ) -> MicroagentObservation | None: - """When a microagent action triggers microagents, create a MicroagentObservation with structured data.""" - # If there's no query, do nothing - query = event.query.strip() - if not query: - return prev_observation + ) -> RecallObservation | None: + """When a microagent action triggers microagents, create a RecallObservation with structured data.""" - assert prev_observation is None or isinstance( - prev_observation, MicroagentObservation - ), f'Expected a MicroagentObservation, but got {type(prev_observation)}' + # Find any matched microagents based on the query + microagent_knowledge = self._find_microagent_knowledge(event.query) - # Process text to find suitable microagents and create a MicroagentObservation. + # Create observation if we have anything + if microagent_knowledge: + obs = RecallObservation( + recall_type=RecallType.KNOWLEDGE, + microagent_knowledge=microagent_knowledge, + content='Retrieved knowledge from microagents', + ) + return obs + return None + + def _find_microagent_knowledge(self, query: str) -> list[MicroagentKnowledge]: + """Find microagent knowledge based on a query. + + Args: + query: The query to search for microagent triggers + + Returns: + A list of MicroagentKnowledge objects for matched triggers + """ recalled_content: list[MicroagentKnowledge] = [] + + # skip empty queries + if not query: + return recalled_content + + # Search for microagent triggers in the query for name, microagent in self.knowledge_microagents.items(): trigger = microagent.match_trigger(query) if trigger: @@ -183,22 +220,7 @@ class Memory: content=microagent.content, ) ) - - if recalled_content: - if prev_observation is not None: - # it may be on the first user message that already found some repo info etc - prev_observation.microagent_knowledge.extend(recalled_content) - else: - # if it's not the first user message, we may not have found any information this step - obs = MicroagentObservation( - recall_type=RecallType.KNOWLEDGE, - microagent_knowledge=recalled_content, - content='Retrieved knowledge from microagents', - ) - - return obs - - return prev_observation + return recalled_content def load_user_workspace_microagents( self, user_microagents: list[BaseMicroAgent] diff --git a/openhands/utils/prompt.py b/openhands/utils/prompt.py index 643af5aa2e..4762b0e442 100644 --- a/openhands/utils/prompt.py +++ b/openhands/utils/prompt.py @@ -76,7 +76,7 @@ class PromptManager: if example_message: message.content.insert(0, TextContent(text=example_message)) - def build_additional_info( + def build_workspace_context( self, repository_info: RepositoryInfo | None, runtime_info: RuntimeInfo | None, diff --git a/tests/unit/test_agent_controller.py b/tests/unit/test_agent_controller.py index 58b8ae7c95..dda09da893 100644 --- a/tests/unit/test_agent_controller.py +++ b/tests/unit/test_agent_controller.py @@ -19,7 +19,7 @@ from openhands.events.event import RecallType from openhands.events.observation import ( ErrorObservation, ) -from openhands.events.observation.agent import MicroagentObservation +from openhands.events.observation.agent import RecallObservation from openhands.events.serialization import event_to_dict from openhands.llm import LLM from openhands.llm.metrics import Metrics, TokenUsage @@ -192,7 +192,7 @@ async def test_run_controller_with_fatal_error(test_event_stream, mock_memory): def on_event_memory(event: Event): if isinstance(event, RecallAction): - microagent_obs = MicroagentObservation( + microagent_obs = RecallObservation( content='Test microagent content', recall_type=RecallType.KNOWLEDGE, ) @@ -249,7 +249,7 @@ async def test_run_controller_stop_with_stuck(test_event_stream, mock_memory): def on_event_memory(event: Event): if isinstance(event, RecallAction): - microagent_obs = MicroagentObservation( + microagent_obs = RecallObservation( content='Test microagent content', recall_type=RecallType.KNOWLEDGE, ) @@ -596,7 +596,7 @@ async def test_run_controller_max_iterations_has_metrics( def on_event_memory(event: Event): if isinstance(event, RecallAction): - microagent_obs = MicroagentObservation( + microagent_obs = RecallObservation( content='Test microagent content', recall_type=RecallType.KNOWLEDGE, ) @@ -718,7 +718,7 @@ async def test_run_controller_with_context_window_exceeded_with_truncation( def on_event_memory(event: Event): if isinstance(event, RecallAction): - microagent_obs = MicroagentObservation( + microagent_obs = RecallObservation( content='Test microagent content', recall_type=RecallType.KNOWLEDGE, ) @@ -795,7 +795,7 @@ async def test_run_controller_with_context_window_exceeded_without_truncation( def on_event_memory(event: Event): if isinstance(event, RecallAction): - microagent_obs = MicroagentObservation( + microagent_obs = RecallObservation( content='Test microagent content', recall_type=RecallType.KNOWLEDGE, ) @@ -845,23 +845,30 @@ async def test_run_controller_with_memory_error(test_event_stream): config = AppConfig() event_stream = test_event_stream + # Create a propert agent that returns an action without an ID agent = MagicMock(spec=Agent) agent.llm = MagicMock(spec=LLM) agent.llm.metrics = Metrics() agent.llm.config = config.get_llm_config() + # Create a real action to return from the mocked step function + def agent_step_fn(state): + return MessageAction(content='Agent returned a message') + + agent.step = agent_step_fn + runtime = MagicMock(spec=Runtime) runtime.event_stream = event_stream # Create a real Memory instance memory = Memory(event_stream=event_stream, sid='test-memory') - # Patch the _on_microagent_action method to raise our test exception - def mock_on_microagent_action(*args, **kwargs): + # Patch the _find_microagent_knowledge method to raise our test exception + def mock_find_microagent_knowledge(*args, **kwargs): raise RuntimeError('Test memory error') with patch.object( - memory, '_on_microagent_action', side_effect=mock_on_microagent_action + memory, '_find_microagent_knowledge', side_effect=mock_find_microagent_knowledge ): state = await run_controller( config=config, diff --git a/tests/unit/test_agent_delegation.py b/tests/unit/test_agent_delegation.py index 006711f19f..39ad87bab7 100644 --- a/tests/unit/test_agent_delegation.py +++ b/tests/unit/test_agent_delegation.py @@ -19,7 +19,7 @@ from openhands.events.action import ( ) from openhands.events.action.agent import RecallAction from openhands.events.event import Event, RecallType -from openhands.events.observation.agent import MicroagentObservation +from openhands.events.observation.agent import RecallObservation from openhands.events.stream import EventStreamSubscriber from openhands.llm.llm import LLM from openhands.llm.metrics import Metrics @@ -86,10 +86,10 @@ async def test_delegation_flow(mock_parent_agent, mock_child_agent, mock_event_s def on_event(event: Event): if isinstance(event, RecallAction): - # create a MicroagentObservation - microagent_observation = MicroagentObservation( + # create a RecallObservation + microagent_observation = RecallObservation( recall_type=RecallType.KNOWLEDGE, - content='microagent', + content='Found info', ) microagent_observation._cause = event.id # ignore attr-defined warning mock_event_stream.add_event(microagent_observation, EventSource.ENVIRONMENT) @@ -111,14 +111,14 @@ async def test_delegation_flow(mock_parent_agent, mock_child_agent, mock_event_s # Give time for the async step() to execute await asyncio.sleep(1) - # Verify that a MicroagentObservation was added to the event stream + # Verify that a RecallObservation was added to the event stream events = list(mock_event_stream.get_events()) assert ( mock_event_stream.get_latest_event_id() == 3 ) # Microagents and AgentChangeState - # a MicroagentObservation and an AgentDelegateAction should be in the list - assert any(isinstance(event, MicroagentObservation) for event in events) + # a RecallObservation and an AgentDelegateAction should be in the list + assert any(isinstance(event, RecallObservation) for event in events) assert any(isinstance(event, AgentDelegateAction) for event in events) # Verify that a delegate agent controller is created diff --git a/tests/unit/test_conversation_memory.py b/tests/unit/test_conversation_memory.py index 5397c63559..21c760280c 100644 --- a/tests/unit/test_conversation_memory.py +++ b/tests/unit/test_conversation_memory.py @@ -22,7 +22,7 @@ from openhands.events.event import ( from openhands.events.observation import CmdOutputObservation from openhands.events.observation.agent import ( MicroagentKnowledge, - MicroagentObservation, + RecallObservation, ) from openhands.events.observation.browse import BrowserOutputObservation from openhands.events.observation.commands import ( @@ -51,7 +51,7 @@ def agent_config(): def conversation_memory(agent_config): prompt_manager = MagicMock(spec=PromptManager) prompt_manager.get_system_message.return_value = 'System message' - prompt_manager.build_additional_info.return_value = ( + prompt_manager.build_workspace_context.return_value = ( 'Formatted repository and runtime info' ) @@ -353,10 +353,10 @@ def test_process_events_with_user_reject_observation(conversation_memory): def test_process_events_with_empty_environment_info(conversation_memory): - """Test that empty environment info observations return an empty list of messages without calling build_additional_info.""" - # Create a MicroagentObservation with empty info + """Test that empty environment info observations return an empty list of messages without calling build_workspace_context.""" + # Create a RecallObservation with empty info - empty_obs = MicroagentObservation( + empty_obs = RecallObservation( recall_type=RecallType.WORKSPACE_CONTEXT, repo_name='', repo_directory='', @@ -382,8 +382,8 @@ def test_process_events_with_empty_environment_info(conversation_memory): assert len(messages) == 1 assert messages[0].role == 'system' - # Verify that build_additional_info was NOT called since all input values were empty - conversation_memory.prompt_manager.build_additional_info.assert_not_called() + # Verify that build_workspace_context was NOT called since all input values were empty + conversation_memory.prompt_manager.build_workspace_context.assert_not_called() def test_process_events_with_function_calling_observation(conversation_memory): @@ -527,8 +527,8 @@ def test_apply_prompt_caching(conversation_memory): def test_process_events_with_environment_microagent_observation(conversation_memory): - """Test processing a MicroagentObservation with ENVIRONMENT info type.""" - obs = MicroagentObservation( + """Test processing a RecallObservation with ENVIRONMENT info type.""" + obs = RecallObservation( recall_type=RecallType.WORKSPACE_CONTEXT, repo_name='test-repo', repo_directory='/path/to/repo', @@ -556,8 +556,8 @@ def test_process_events_with_environment_microagent_observation(conversation_mem assert result.content[0].text == 'Formatted repository and runtime info' # Verify the prompt_manager was called with the correct parameters - conversation_memory.prompt_manager.build_additional_info.assert_called_once() - call_args = conversation_memory.prompt_manager.build_additional_info.call_args[1] + conversation_memory.prompt_manager.build_workspace_context.assert_called_once() + call_args = conversation_memory.prompt_manager.build_workspace_context.call_args[1] assert isinstance(call_args['repository_info'], RepositoryInfo) assert call_args['repository_info'].repo_name == 'test-repo' assert call_args['repository_info'].repo_directory == '/path/to/repo' @@ -572,7 +572,7 @@ def test_process_events_with_environment_microagent_observation(conversation_mem def test_process_events_with_knowledge_microagent_microagent_observation( conversation_memory, ): - """Test processing a MicroagentObservation with KNOWLEDGE type.""" + """Test processing a RecallObservation with KNOWLEDGE type.""" microagent_knowledge = [ MicroagentKnowledge( name='test_agent', @@ -591,7 +591,7 @@ def test_process_events_with_knowledge_microagent_microagent_observation( ), ] - obs = MicroagentObservation( + obs = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=microagent_knowledge, content='Retrieved knowledge from microagents', @@ -634,11 +634,11 @@ def test_process_events_with_knowledge_microagent_microagent_observation( def test_process_events_with_microagent_observation_extensions_disabled( agent_config, conversation_memory ): - """Test processing a MicroagentObservation when prompt extensions are disabled.""" + """Test processing a RecallObservation when prompt extensions are disabled.""" # Modify the agent config to disable prompt extensions agent_config.enable_prompt_extensions = False - obs = MicroagentObservation( + obs = RecallObservation( recall_type=RecallType.WORKSPACE_CONTEXT, repo_name='test-repo', repo_directory='/path/to/repo', @@ -656,18 +656,18 @@ def test_process_events_with_microagent_observation_extensions_disabled( vision_is_active=False, ) - # When prompt extensions are disabled, the MicroagentObservation should be ignored + # When prompt extensions are disabled, the RecallObservation should be ignored assert len(messages) == 1 # Only the initial system message assert messages[0].role == 'system' # Verify the prompt_manager was not called - conversation_memory.prompt_manager.build_additional_info.assert_not_called() + conversation_memory.prompt_manager.build_workspace_context.assert_not_called() conversation_memory.prompt_manager.build_microagent_info.assert_not_called() def test_process_events_with_empty_microagent_knowledge(conversation_memory): - """Test processing a MicroagentObservation with empty microagent knowledge.""" - obs = MicroagentObservation( + """Test processing a RecallObservation with empty microagent knowledge.""" + obs = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[], content='Retrieved knowledge from microagents', @@ -693,7 +693,7 @@ def test_process_events_with_empty_microagent_knowledge(conversation_memory): def test_conversation_memory_processes_microagent_observation(prompt_dir): - """Test that ConversationMemory processes MicroagentObservations correctly.""" + """Test that ConversationMemory processes RecallObservations correctly.""" # Create a microagent_info.j2 template file template_path = os.path.join(prompt_dir, 'microagent_info.j2') if not os.path.exists(template_path): @@ -722,8 +722,8 @@ It may or may not be relevant to the user's request. config=agent_config, prompt_manager=prompt_manager ) - # Create a MicroagentObservation with microagent knowledge - microagent_observation = MicroagentObservation( + # Create a RecallObservation with microagent knowledge + microagent_observation = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -761,7 +761,7 @@ This is triggered content for testing. def test_conversation_memory_processes_environment_microagent_observation(prompt_dir): - """Test that ConversationMemory processes environment info MicroagentObservations correctly.""" + """Test that ConversationMemory processes environment info RecallObservations correctly.""" # Create an additional_info.j2 template file template_path = os.path.join(prompt_dir, 'additional_info.j2') if not os.path.exists(template_path): @@ -802,8 +802,8 @@ each of which has a corresponding port: config=agent_config, prompt_manager=prompt_manager ) - # Create a MicroagentObservation with environment info - microagent_observation = MicroagentObservation( + # Create a RecallObservation with environment info + microagent_observation = RecallObservation( recall_type=RecallType.WORKSPACE_CONTEXT, repo_name='owner/repo', repo_directory='/workspace/repo', @@ -839,13 +839,13 @@ each of which has a corresponding port: def test_process_events_with_microagent_observation_deduplication(conversation_memory): - """Test that MicroagentObservations are properly deduplicated based on agent name. + """Test that RecallObservations are properly deduplicated based on agent name. The deduplication logic should keep the FIRST occurrence of each microagent and filter out later occurrences to avoid redundant information. """ - # Create a sequence of MicroagentObservations with overlapping agents - obs1 = MicroagentObservation( + # Create a sequence of RecallObservations with overlapping agents + obs1 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -867,7 +867,7 @@ def test_process_events_with_microagent_observation_deduplication(conversation_m content='First retrieval', ) - obs2 = MicroagentObservation( + obs2 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -879,7 +879,7 @@ def test_process_events_with_microagent_observation_deduplication(conversation_m content='Second retrieval', ) - obs3 = MicroagentObservation( + obs3 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -918,8 +918,8 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent conversation_memory, ): """Test that disabled agents are filtered out and deduplication keeps the first occurrence.""" - # Create a sequence of MicroagentObservations with disabled agents - obs1 = MicroagentObservation( + # Create a sequence of RecallObservations with disabled agents + obs1 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -936,7 +936,7 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent content='First retrieval', ) - obs2 = MicroagentObservation( + obs2 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -973,8 +973,8 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent def test_process_events_with_microagent_observation_deduplication_empty( conversation_memory, ): - """Test that empty MicroagentObservations are handled correctly.""" - obs = MicroagentObservation( + """Test that empty RecallObservations are handled correctly.""" + obs = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[], content='Empty retrieval', @@ -991,7 +991,7 @@ def test_process_events_with_microagent_observation_deduplication_empty( vision_is_active=False, ) - # Verify that empty MicroagentObservations are handled gracefully + # Verify that empty RecallObservations are handled gracefully assert ( len(messages) == 1 ) # system message, because an empty microagent is not added to Messages @@ -999,8 +999,8 @@ def test_process_events_with_microagent_observation_deduplication_empty( def test_has_agent_in_earlier_events(conversation_memory): """Test the _has_agent_in_earlier_events helper method.""" - # Create test MicroagentObservations - obs1 = MicroagentObservation( + # Create test RecallObservations + obs1 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -1012,7 +1012,7 @@ def test_has_agent_in_earlier_events(conversation_memory): content='First retrieval', ) - obs2 = MicroagentObservation( + obs2 = RecallObservation( recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ MicroagentKnowledge( @@ -1024,7 +1024,7 @@ def test_has_agent_in_earlier_events(conversation_memory): content='Second retrieval', ) - obs3 = MicroagentObservation( + obs3 = RecallObservation( recall_type=RecallType.WORKSPACE_CONTEXT, content='Environment info', ) diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index c0c354906a..5bf1ceda80 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -14,7 +14,7 @@ from openhands.events.action.agent import RecallAction from openhands.events.action.message import MessageAction from openhands.events.event import EventSource from openhands.events.observation.agent import ( - MicroagentObservation, + RecallObservation, RecallType, ) from openhands.events.stream import EventStream @@ -74,7 +74,7 @@ async def test_memory_on_event_exception_handling(memory, event_stream): # Mock Memory method to raise an exception with patch.object( - memory, '_on_first_microagent_action', side_effect=Exception('Test error') + memory, '_on_workspace_context_recall', side_effect=Exception('Test error') ): state = await run_controller( config=AppConfig(), @@ -93,10 +93,10 @@ async def test_memory_on_event_exception_handling(memory, event_stream): @pytest.mark.asyncio -async def test_memory_on_first_microagent_action_exception_handling( +async def test_memory_on_workspace_context_recall_exception_handling( memory, event_stream ): - """Test that exceptions in Memory._on_first_microagent_action are properly handled via status callback.""" + """Test that exceptions in Memory._on_workspace_context_recall are properly handled via status callback.""" # Create a dummy agent for the controller agent = MagicMock(spec=Agent) @@ -108,11 +108,11 @@ async def test_memory_on_first_microagent_action_exception_handling( runtime = MagicMock(spec=Runtime) runtime.event_stream = event_stream - # Mock Memory._on_first_microagent_action to raise an exception + # Mock Memory._on_workspace_context_recall to raise an exception with patch.object( memory, - '_on_first_microagent_action', - side_effect=Exception('Test error from _on_first_microagent_action'), + '_find_microagent_knowledge', + side_effect=Exception('Test error from _find_microagent_knowledge'), ): state = await run_controller( config=AppConfig(), @@ -130,12 +130,13 @@ async def test_memory_on_first_microagent_action_exception_handling( assert state.last_error == 'Error: Exception' -def test_memory_with_microagents(): +@pytest.mark.asyncio +async def test_memory_with_microagents(): """Test that Memory loads microagents from the global directory and processes microagent actions. This test verifies that: 1. Memory loads microagents from the global GLOBAL_MICROAGENTS_DIR - 2. When a microagent action with a trigger word is processed, a MicroagentObservation is created + 2. When a microagent action with a trigger word is processed, a RecallObservation is created """ # Create a mock event stream event_stream = MagicMock(spec=EventStream) @@ -158,6 +159,9 @@ def test_memory_with_microagents(): query='Hello, flarglebargle!', recall_type=RecallType.KNOWLEDGE ) + # Set the source to USER + microagent_action._source = EventSource.USER # type: ignore[attr-defined] + # Mock the event_stream.add_event method added_events = [] @@ -173,12 +177,12 @@ def test_memory_with_microagents(): added_events.clear() # Process the microagent action - memory.on_event(microagent_action) + await memory._on_event(microagent_action) - # Verify a MicroagentObservation was added to the event stream + # Verify a RecallObservation was added to the event stream assert len(added_events) == 1 observation, source = added_events[0] - assert isinstance(observation, MicroagentObservation) + assert isinstance(observation, RecallObservation) assert source == EventSource.ENVIRONMENT assert observation.recall_type == RecallType.KNOWLEDGE assert len(observation.microagent_knowledge) == 1 @@ -188,7 +192,7 @@ def test_memory_with_microagents(): def test_memory_repository_info(prompt_dir): - """Test that Memory adds repository info to MicroagentObservations.""" + """Test that Memory adds repository info to RecallObservations.""" # Create an in-memory file store and real event stream file_store = InMemoryFileStore() event_stream = EventStream(sid='test-session', file_store=file_store) @@ -241,15 +245,15 @@ REPOSITORY INSTRUCTIONS: This is a test repository. # Get all events from the stream events = list(event_stream.get_events()) - # Find the MicroagentObservation event + # Find the RecallObservation event microagent_obs_events = [ - event for event in events if isinstance(event, MicroagentObservation) + event for event in events if isinstance(event, RecallObservation) ] - # We should have at least one MicroagentObservation + # We should have at least one RecallObservation assert len(microagent_obs_events) > 0 - # Get the first MicroagentObservation + # Get the first RecallObservation observation = microagent_obs_events[0] assert observation.recall_type == RecallType.WORKSPACE_CONTEXT assert observation.repo_name == 'owner/repo' diff --git a/tests/unit/test_observation_serialization.py b/tests/unit/test_observation_serialization.py index 0596f0bcfd..123d7ad8fd 100644 --- a/tests/unit/test_observation_serialization.py +++ b/tests/unit/test_observation_serialization.py @@ -5,8 +5,8 @@ from openhands.events.observation import ( CmdOutputMetadata, CmdOutputObservation, FileEditObservation, - MicroagentObservation, Observation, + RecallObservation, ) from openhands.events.observation.agent import MicroagentKnowledge from openhands.events.serialization import ( @@ -245,9 +245,9 @@ def test_file_edit_observation_legacy_serialization(): def test_microagent_observation_serialization(): original_observation_dict = { - 'observation': 'microagent', + 'observation': 'recall', 'content': '', - 'message': "**MicroagentObservation**\nrecall_type=RecallType.WORKSPACE_CONTEXT, repo_name=some_repo_name, repo_instructions=complex_repo_instruc..., runtime_hosts={'host1': 8080, 'host2': 8081}, additional_agent_instructions=You know it all abou...", + 'message': 'Added workspace context', 'extras': { 'recall_type': 'workspace_context', 'repo_name': 'some_repo_name', @@ -258,14 +258,14 @@ def test_microagent_observation_serialization(): 'microagent_knowledge': [], }, } - serialization_deserialization(original_observation_dict, MicroagentObservation) + serialization_deserialization(original_observation_dict, RecallObservation) def test_microagent_observation_microagent_knowledge_serialization(): original_observation_dict = { - 'observation': 'microagent', + 'observation': 'recall', 'content': '', - 'message': '**MicroagentObservation**\nrecall_type=RecallType.KNOWLEDGE, repo_name=, repo_instructions=..., runtime_hosts={}, additional_agent_instructions=..., microagent_knowledge=microagent1, microagent2', + 'message': 'Added microagent knowledge', 'extras': { 'recall_type': 'knowledge', 'repo_name': '', @@ -287,13 +287,13 @@ def test_microagent_observation_microagent_knowledge_serialization(): ], }, } - serialization_deserialization(original_observation_dict, MicroagentObservation) + serialization_deserialization(original_observation_dict, RecallObservation) def test_microagent_observation_knowledge_microagent_serialization(): - """Test serialization of a MicroagentObservation with KNOWLEDGE_MICROAGENT type.""" - # Create a MicroagentObservation with microagent knowledge content - original = MicroagentObservation( + """Test serialization of a RecallObservation with KNOWLEDGE_MICROAGENT type.""" + # Create a RecallObservation with microagent knowledge content + original = RecallObservation( content='Knowledge microagent information', recall_type=RecallType.KNOWLEDGE, microagent_knowledge=[ @@ -314,13 +314,13 @@ def test_microagent_observation_knowledge_microagent_serialization(): serialized = event_to_dict(original) # Verify serialized data structure - assert serialized['observation'] == ObservationType.MICROAGENT + assert serialized['observation'] == ObservationType.RECALL assert serialized['content'] == 'Knowledge microagent information' assert serialized['extras']['recall_type'] == RecallType.KNOWLEDGE.value assert len(serialized['extras']['microagent_knowledge']) == 2 assert serialized['extras']['microagent_knowledge'][0]['trigger'] == 'python' - # Deserialize back to MicroagentObservation + # Deserialize back to RecallObservation deserialized = observation_from_dict(serialized) # Verify properties are preserved @@ -336,9 +336,9 @@ def test_microagent_observation_knowledge_microagent_serialization(): def test_microagent_observation_environment_serialization(): - """Test serialization of a MicroagentObservation with ENVIRONMENT type.""" - # Create a MicroagentObservation with environment info - original = MicroagentObservation( + """Test serialization of a RecallObservation with ENVIRONMENT type.""" + # Create a RecallObservation with environment info + original = RecallObservation( content='Environment information', recall_type=RecallType.WORKSPACE_CONTEXT, repo_name='OpenHands', @@ -352,7 +352,7 @@ def test_microagent_observation_environment_serialization(): serialized = event_to_dict(original) # Verify serialized data structure - assert serialized['observation'] == ObservationType.MICROAGENT + assert serialized['observation'] == ObservationType.RECALL assert serialized['content'] == 'Environment information' assert serialized['extras']['recall_type'] == RecallType.WORKSPACE_CONTEXT.value assert serialized['extras']['repo_name'] == 'OpenHands' @@ -364,7 +364,7 @@ def test_microagent_observation_environment_serialization(): serialized['extras']['additional_agent_instructions'] == 'You know it all about this runtime' ) - # Deserialize back to MicroagentObservation + # Deserialize back to RecallObservation deserialized = observation_from_dict(serialized) # Verify properties are preserved @@ -382,11 +382,11 @@ def test_microagent_observation_environment_serialization(): def test_microagent_observation_combined_serialization(): - """Test serialization of a MicroagentObservation with both types of information.""" - # Create a MicroagentObservation with both environment and microagent info + """Test serialization of a RecallObservation with both types of information.""" + # Create a RecallObservation with both environment and microagent info # Note: In practice, recall_type would still be one specific type, # but the object could contain both types of fields - original = MicroagentObservation( + original = RecallObservation( content='Combined information', recall_type=RecallType.WORKSPACE_CONTEXT, # Environment info @@ -419,7 +419,7 @@ def test_microagent_observation_combined_serialization(): serialized['extras']['additional_agent_instructions'] == 'You know it all about this runtime' ) - # Deserialize back to MicroagentObservation + # Deserialize back to RecallObservation deserialized = observation_from_dict(serialized) # Verify all properties are preserved diff --git a/tests/unit/test_prompt_manager.py b/tests/unit/test_prompt_manager.py index 0d64a1f6f6..1c01eb80c4 100644 --- a/tests/unit/test_prompt_manager.py +++ b/tests/unit/test_prompt_manager.py @@ -51,7 +51,7 @@ At the user's request, repository {{ repository_info.repo_name }} has been clone assert 'System prompt: bar' in system_msg # Test building additional info - additional_info = manager.build_additional_info( + additional_info = manager.build_workspace_context( repository_info=repo_info, runtime_info=None, repo_instructions='' ) assert '' in additional_info @@ -199,7 +199,7 @@ def test_add_turns_left_reminder(prompt_dir): ) -def test_build_additional_info_with_repo_and_runtime(prompt_dir): +def test_build_workspace_context_with_repo_and_runtime(prompt_dir): """Test building additional info with repository and runtime information.""" # Create an additional_info.j2 template file with open(os.path.join(prompt_dir, 'additional_info.j2'), 'w') as f: @@ -245,7 +245,7 @@ each of which has a corresponding port: repo_instructions = 'This repository contains important code.' # Build additional info - result = manager.build_additional_info( + result = manager.build_workspace_context( repository_info=repo_info, runtime_info=runtime_info, repo_instructions=repo_instructions, diff --git a/tests/unit/test_truncation.py b/tests/unit/test_truncation.py index c8218194ce..d3296b67b8 100644 --- a/tests/unit/test_truncation.py +++ b/tests/unit/test_truncation.py @@ -23,7 +23,13 @@ def mock_event_stream(): def mock_agent(): agent = MagicMock() agent.llm = MagicMock() - agent.llm.config = MagicMock() + + # Create a step function that returns an action without an ID + def agent_step_fn(state): + return MessageAction(content='Agent returned a message') + + agent.step = agent_step_fn + return agent