diff --git a/examples/01_standalone_sdk/25_agent_delegation.py b/examples/01_standalone_sdk/25_agent_delegation.py index 4e52fa16b5..ac15efe302 100644 --- a/examples/01_standalone_sdk/25_agent_delegation.py +++ b/examples/01_standalone_sdk/25_agent_delegation.py @@ -18,9 +18,8 @@ Tool, get_logger, ) -from openhands.sdk.conversation import DefaultConversationVisualizer from openhands.sdk.tool import register_tool -from openhands.tools.delegate import DelegateTool +from openhands.tools.delegate import DelegateTool, DelegationVisualizer from openhands.tools.preset.default import get_default_tools @@ -51,7 +50,7 @@ conversation = Conversation( agent=main_agent, workspace=cwd, - visualizer=DefaultConversationVisualizer(name="Delegator"), + visualizer=DelegationVisualizer(name="Delegator"), ) task_message = ( diff --git a/examples/01_standalone_sdk/26_custom_visualizer.py b/examples/01_standalone_sdk/26_custom_visualizer.py index c6aed6884a..d321dec18d 100644 --- a/examples/01_standalone_sdk/26_custom_visualizer.py +++ b/examples/01_standalone_sdk/26_custom_visualizer.py @@ -26,15 +26,6 @@ class MinimalVisualizer(ConversationVisualizerBase): """A minimal visualizer that print the raw events as they occur.""" - def __init__(self, name: str | None = None): - """Initialize the minimal progress visualizer. - - Args: - name: Optional name to identify the agent/conversation. - """ - # Initialize parent - state will be set later via initialize() - super().__init__(name=name) - def on_event(self, event: Event) -> None: """Handle events for minimal progress visualization.""" print(f"\n\n[EVENT] {type(event).__name__}: {event.model_dump_json()[:200]}...") diff --git a/openhands-sdk/openhands/sdk/conversation/base.py b/openhands-sdk/openhands/sdk/conversation/base.py index 6213061b55..3f259cc42f 100644 --- a/openhands-sdk/openhands/sdk/conversation/base.py +++ b/openhands-sdk/openhands/sdk/conversation/base.py @@ -118,8 +118,17 @@ def state(self) -> ConversationStateProtocol: ... def conversation_stats(self) -> ConversationStats: ... @abstractmethod - def send_message(self, message: str | Message) -> None: - """Send a message to the agent.""" + def send_message(self, message: str | Message, sender: str | None = None) -> None: + """Send a message to the agent. + + Args: + message: Either a string (which will be converted to a user message) + or a Message object + sender: Optional identifier of the sender. Can be used to track + message origin in multi-agent scenarios. For example, when + one agent delegates to another, the sender can be set to + identify which agent is sending the message. + """ ... @abstractmethod diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 25bf893f92..3de6508d0c 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -185,12 +185,16 @@ def stuck_detector(self) -> StuckDetector | None: return self._stuck_detector @observe(name="conversation.send_message") - def send_message(self, message: str | Message) -> None: + def send_message(self, message: str | Message, sender: str | None = None) -> None: """Send a message to the agent. Args: message: Either a string (which will be converted to a user message) or a Message object + sender: Optional identifier of the sender. Can be used to track + message origin in multi-agent scenarios. For example, when + one agent delegates to another, the sender can be set to + identify which agent is sending the message. """ # Convert string to Message if needed if isinstance(message, str): @@ -233,6 +237,7 @@ def send_message(self, message: str | Message) -> None: llm_message=message, activated_skills=activated_skill_names, extended_content=extended_content, + sender=sender, ) self._on_event(user_msg_event) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 9b53ee3ea7..20274ce1ea 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -553,7 +553,7 @@ def stuck_detector(self): ) @observe(name="conversation.send_message") - def send_message(self, message: str | Message) -> None: + def send_message(self, message: str | Message, sender: str | None = None) -> None: if isinstance(message, str): message = Message(role="user", content=[TextContent(text=message)]) assert message.role == "user", ( @@ -564,6 +564,8 @@ def send_message(self, message: str | Message) -> None: "content": [c.model_dump() for c in message.content], "run": False, # Mirror local semantics; explicit run() must be called } + if sender is not None: + payload["sender"] = sender _send_request( self._client, "POST", f"/api/conversations/{self._id}/events", json=payload ) diff --git a/openhands-sdk/openhands/sdk/conversation/visualizer/base.py b/openhands-sdk/openhands/sdk/conversation/visualizer/base.py index 1a110ca018..7f6f6841d7 100644 --- a/openhands-sdk/openhands/sdk/conversation/visualizer/base.py +++ b/openhands-sdk/openhands/sdk/conversation/visualizer/base.py @@ -18,7 +18,7 @@ class ConversationVisualizerBase(ABC): The typical usage pattern: 1. Create a visualizer instance: - `viz = MyVisualizer(name="agent1")` + `viz = MyVisualizer()` 2. Pass it to Conversation: `conv = Conversation(agent, visualizer=viz)` 3. Conversation automatically calls `viz.initialize(state)` to attach the state @@ -28,21 +28,10 @@ class ConversationVisualizerBase(ABC): Conversation will then calls `MyVisualizer()` followed by `initialize(state)` """ - _name: str | None _state: "ConversationStateProtocol | None" - def __init__( - self, - name: str | None = None, - ): - """Initialize the visualizer base. - - Args: - name: Optional name to prefix in panel titles to identify - which agent/conversation is speaking. Will be - capitalized automatically. - """ - self._name = name.capitalize() if name else None + def __init__(self): + """Initialize the visualizer base.""" self._state = None @final @@ -60,14 +49,6 @@ def initialize(self, state: "ConversationStateProtocol") -> None: """ self._state = state - def update_name(self, name: str | None) -> None: - """Update the name used for visualization. - - Args: - name: New name to use for visualization - """ - self._name = name.capitalize() if name else None - @property def conversation_stats(self) -> "ConversationStats | None": """Get conversation stats from the state.""" diff --git a/openhands-sdk/openhands/sdk/conversation/visualizer/default.py b/openhands-sdk/openhands/sdk/conversation/visualizer/default.py index 7bdb6ba2db..f3f7bfc0b9 100644 --- a/openhands-sdk/openhands/sdk/conversation/visualizer/default.py +++ b/openhands-sdk/openhands/sdk/conversation/visualizer/default.py @@ -60,15 +60,12 @@ class DefaultConversationVisualizer(ConversationVisualizerBase): def __init__( self, - name: str | None = None, highlight_regex: dict[str, str] | None = DEFAULT_HIGHLIGHT_REGEX, skip_user_messages: bool = False, ): """Initialize the visualizer. Args: - name: Optional name to prefix in panel titles to identify - which agent/conversation is speaking. highlight_regex: Dictionary mapping regex patterns to Rich color styles for highlighting keywords in the visualizer. For example: {"Reasoning:": "bold blue", @@ -76,9 +73,7 @@ def __init__( skip_user_messages: If True, skip displaying user messages. Useful for scenarios where user input is not relevant to show. """ - super().__init__( - name=name, - ) + super().__init__() self._console = Console() self._skip_user_messages = skip_user_messages self._highlight_patterns = highlight_regex or {} @@ -126,10 +121,7 @@ def _create_event_panel(self, event: Event) -> Panel | None: # Determine panel styling based on event type if isinstance(event, SystemPromptEvent): - title = f"[bold {_SYSTEM_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"System Prompt[/bold {_SYSTEM_COLOR}]" + title = f"[bold {_SYSTEM_COLOR}]System Prompt[/bold {_SYSTEM_COLOR}]" return Panel( content, title=title, @@ -139,13 +131,13 @@ def _create_event_panel(self, event: Event) -> Panel | None: ) elif isinstance(event, ActionEvent): # Check if action is None (non-executable) - title = f"[bold {_ACTION_COLOR}]" - if self._name: - title += f"{self._name} " if event.action is None: - title += f"Agent Action (Not Executed)[/bold {_ACTION_COLOR}]" + title = ( + f"[bold {_ACTION_COLOR}]Agent Action (Not Executed)" + f"[/bold {_ACTION_COLOR}]" + ) else: - title += f"Agent Action[/bold {_ACTION_COLOR}]" + title = f"[bold {_ACTION_COLOR}]Agent Action[/bold {_ACTION_COLOR}]" return Panel( content, title=title, @@ -155,10 +147,9 @@ def _create_event_panel(self, event: Event) -> Panel | None: expand=True, ) elif isinstance(event, ObservationEvent): - title = f"[bold {_OBSERVATION_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Observation[/bold {_OBSERVATION_COLOR}]" + title = ( + f"[bold {_OBSERVATION_COLOR}]Observation[/bold {_OBSERVATION_COLOR}]" + ) return Panel( content, title=title, @@ -167,10 +158,7 @@ def _create_event_panel(self, event: Event) -> Panel | None: expand=True, ) elif isinstance(event, UserRejectObservation): - title = f"[bold {_ERROR_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"User Rejected Action[/bold {_ERROR_COLOR}]" + title = f"[bold {_ERROR_COLOR}]User Rejected Action[/bold {_ERROR_COLOR}]" return Panel( content, title=title, @@ -193,20 +181,14 @@ def _create_event_panel(self, event: Event) -> Panel | None: } role_color = role_colors.get(event.llm_message.role, "white") - # "User Message To [Name] Agent" for user - # "Message from [Name] Agent" for agent - agent_name = f"{self._name} " if self._name else "" - + # Simple titles for base visualizer if event.llm_message.role == "user": - title_text = ( - f"[bold {role_color}]User Message to " - f"{agent_name}Agent[/bold {role_color}]" - ) + title_text = f"[bold {role_color}]Message from User[/bold {role_color}]" else: title_text = ( - f"[bold {role_color}]Message from " - f"{agent_name}Agent[/bold {role_color}]" + f"[bold {role_color}]Message from Agent[/bold {role_color}]" ) + return Panel( content, title=title_text, @@ -216,10 +198,7 @@ def _create_event_panel(self, event: Event) -> Panel | None: expand=True, ) elif isinstance(event, AgentErrorEvent): - title = f"[bold {_ERROR_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Agent Error[/bold {_ERROR_COLOR}]" + title = f"[bold {_ERROR_COLOR}]Agent Error[/bold {_ERROR_COLOR}]" return Panel( content, title=title, @@ -229,10 +208,7 @@ def _create_event_panel(self, event: Event) -> Panel | None: expand=True, ) elif isinstance(event, PauseEvent): - title = f"[bold {_PAUSE_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"User Paused[/bold {_PAUSE_COLOR}]" + title = f"[bold {_PAUSE_COLOR}]User Paused[/bold {_PAUSE_COLOR}]" return Panel( content, title=title, @@ -241,10 +217,7 @@ def _create_event_panel(self, event: Event) -> Panel | None: expand=True, ) elif isinstance(event, Condensation): - title = f"[bold {_SYSTEM_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Condensation[/bold {_SYSTEM_COLOR}]" + title = f"[bold {_SYSTEM_COLOR}]Condensation[/bold {_SYSTEM_COLOR}]" return Panel( content, title=title, @@ -254,10 +227,7 @@ def _create_event_panel(self, event: Event) -> Panel | None: ) elif isinstance(event, CondensationRequest): - title = f"[bold {_SYSTEM_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Condensation Request[/bold {_SYSTEM_COLOR}]" + title = f"[bold {_SYSTEM_COLOR}]Condensation Request[/bold {_SYSTEM_COLOR}]" return Panel( content, title=title, @@ -267,10 +237,10 @@ def _create_event_panel(self, event: Event) -> Panel | None: ) else: # Fallback panel for unknown event types - title = f"[bold {_ERROR_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"UNKNOWN Event: {event.__class__.__name__}[/bold {_ERROR_COLOR}]" + title = ( + f"[bold {_ERROR_COLOR}]UNKNOWN Event: " + f"{event.__class__.__name__}[/bold {_ERROR_COLOR}]" + ) return Panel( content, title=title, diff --git a/openhands-sdk/openhands/sdk/event/llm_convertible/message.py b/openhands-sdk/openhands/sdk/event/llm_convertible/message.py index 861a34e095..c71b27ac2f 100644 --- a/openhands-sdk/openhands/sdk/event/llm_convertible/message.py +++ b/openhands-sdk/openhands/sdk/event/llm_convertible/message.py @@ -43,6 +43,13 @@ class MessageEvent(LLMConvertibleEvent): extended_content: list[TextContent] = Field( default_factory=list, description="List of content added by agent context" ) + sender: str | None = Field( + default=None, + description=( + "Optional identifier of the sender. " + "Can be used to track message origin in multi-agent scenarios." + ), + ) @property def reasoning_content(self) -> str: diff --git a/openhands-tools/openhands/tools/delegate/__init__.py b/openhands-tools/openhands/tools/delegate/__init__.py index ea9f3f813e..809194e914 100644 --- a/openhands-tools/openhands/tools/delegate/__init__.py +++ b/openhands-tools/openhands/tools/delegate/__init__.py @@ -6,6 +6,7 @@ DelegateTool, ) from openhands.tools.delegate.impl import DelegateExecutor +from openhands.tools.delegate.visualizer import DelegationVisualizer __all__ = [ @@ -13,4 +14,5 @@ "DelegateObservation", "DelegateExecutor", "DelegateTool", + "DelegationVisualizer", ] diff --git a/openhands-tools/openhands/tools/delegate/impl.py b/openhands-tools/openhands/tools/delegate/impl.py index 8ba76e1bbc..febd01a736 100644 --- a/openhands-tools/openhands/tools/delegate/impl.py +++ b/openhands-tools/openhands/tools/delegate/impl.py @@ -5,10 +5,10 @@ from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.response_utils import get_agent_final_response -from openhands.sdk.conversation.visualizer import ConversationVisualizerBase from openhands.sdk.logger import get_logger from openhands.sdk.tool.tool import ToolExecutor from openhands.tools.delegate.definition import DelegateObservation +from openhands.tools.delegate.visualizer import DelegationVisualizer from openhands.tools.preset.default import get_default_agent @@ -111,19 +111,22 @@ def _spawn_agents(self, action: "DelegateAction") -> DelegateObservation: ), ) - if isinstance(parent_visualizer, ConversationVisualizerBase): - sub_visualizer = parent_visualizer.__class__(name=agent_id) - elif isinstance(parent_visualizer, type) and issubclass( - parent_visualizer, ConversationVisualizerBase - ): - sub_visualizer = parent_visualizer(name=agent_id) + # If parent uses DelegationVisualizer, create sub-agent visualizer + # Pass raw agent_id - visualizer handles formatting + if isinstance(parent_visualizer, DelegationVisualizer): + sub_visualizer = DelegationVisualizer( + name=agent_id, + highlight_regex=parent_visualizer._highlight_patterns, + skip_user_messages=parent_visualizer._skip_user_messages, + ) else: + # No visualizer for sub-agents if parent doesn't use one sub_visualizer = None sub_conversation = LocalConversation( agent=worker_agent, workspace=workspace_path, - visualize=sub_visualizer, + visualizer=sub_visualizer, ) self._sub_agents[agent_id] = sub_conversation @@ -180,11 +183,25 @@ def _delegate_tasks(self, action: "DelegateAction") -> "DelegateObservation": results = {} errors = {} - def run_task(agent_id: str, conversation: LocalConversation, task: str): + # Get the parent agent's name from the visualizer + parent_conversation = self.parent_conversation + parent_name = None + if hasattr(parent_conversation, "_visualizer"): + visualizer = parent_conversation._visualizer + if isinstance(visualizer, DelegationVisualizer): + parent_name = visualizer._name + + def run_task( + agent_id: str, + conversation: LocalConversation, + task: str, + parent_name: str | None, + ): """Run a single task on a sub-agent.""" try: logger.info(f"Sub-agent {agent_id} starting task: {task[:100]}...") - conversation.send_message(task) + # Pass raw parent_name - visualizer handles formatting + conversation.send_message(task, sender=parent_name) conversation.run() # Extract the final response using get_agent_final_response @@ -208,7 +225,7 @@ def run_task(agent_id: str, conversation: LocalConversation, task: str): conversation = self._sub_agents[agent_id] thread = threading.Thread( target=run_task, - args=(agent_id, conversation, task), + args=(agent_id, conversation, task, parent_name), name=f"Task-{agent_id}", ) threads.append(thread) diff --git a/openhands-tools/openhands/tools/delegate/visualizer.py b/openhands-tools/openhands/tools/delegate/visualizer.py new file mode 100644 index 0000000000..baf6082696 --- /dev/null +++ b/openhands-tools/openhands/tools/delegate/visualizer.py @@ -0,0 +1,262 @@ +""" +Delegation-specific visualizer that shows sender/receiver information for +multi-agent delegation. +""" + +from rich.panel import Panel + +from openhands.sdk.conversation.visualizer.default import ( + _ACTION_COLOR, + _OBSERVATION_COLOR, + _PANEL_PADDING, + _SYSTEM_COLOR, + DefaultConversationVisualizer, +) +from openhands.sdk.event import ( + ActionEvent, + MessageEvent, + ObservationEvent, + SystemPromptEvent, +) +from openhands.sdk.event.base import Event + + +class DelegationVisualizer(DefaultConversationVisualizer): + """ + Custom visualizer for agent delegation that shows detailed sender/receiver + information. + + This visualizer extends the default visualizer to provide clearer + visualization of multi-agent conversations during delegation scenarios. + It shows: + - Who sent each message (e.g., "Delegator", "Lodging Expert") + - Who the intended recipient is + - Clear directional flow between agents + + Example titles: + - "Delegator Message to Lodging Expert" + - "Lodging Expert Message to Delegator" + - "Message from User to Delegator" + """ + + _name: str | None + + def __init__( + self, + name: str | None = None, + highlight_regex: dict[str, str] | None = None, + skip_user_messages: bool = False, + ): + """Initialize the delegation visualizer. + + Args: + name: Agent name to display in panel titles for delegation context. + highlight_regex: Dictionary mapping regex patterns to Rich color styles + for highlighting keywords in the visualizer. + skip_user_messages: If True, skip displaying user messages. + """ + super().__init__( + highlight_regex=highlight_regex, + skip_user_messages=skip_user_messages, + ) + self._name = name + + @staticmethod + def _format_agent_name(name: str) -> str: + """ + Convert snake_case or camelCase agent name to Title Case for display. + + Args: + name: Agent name in snake_case (e.g., "lodging_expert") or + camelCase (e.g., "MainAgent") or already formatted + (e.g., "Main Agent") + + Returns: + Formatted name in Title Case (e.g., "Lodging Expert" or "Main Agent") + + Examples: + >>> DelegationVisualizer._format_agent_name("lodging_expert") + 'Lodging Expert' + >>> DelegationVisualizer._format_agent_name("MainAgent") + 'Main Agent' + >>> DelegationVisualizer._format_agent_name("main_delegator") + 'Main Delegator' + >>> DelegationVisualizer._format_agent_name("Main Agent") + 'Main Agent' + """ + # If already has spaces, assume it's already formatted + if " " in name: + return name + + # Handle snake_case by replacing underscores with spaces + if "_" in name: + return name.replace("_", " ").title() + + # Handle camelCase/PascalCase by inserting spaces before capitals + import re + + # Insert space before each capital letter (except the first one) + spaced = re.sub(r"(? Panel | None: + """ + Override event panel creation to add agent names to titles. + + For system prompts, actions, and observations, prepend the agent name + (e.g., "Delegator Agent System Prompt", "Delegator Agent Action", + "Lodging Expert Agent Observation"). + For messages, delegate to the specialized message handler. + + Args: + event: The event to visualize + + Returns: + A Rich Panel with agent-specific title, or None if visualization fails + """ + # For message events, use our specialized handler + if isinstance(event, MessageEvent): + return self._create_message_event_panel(event) + + # For system prompts, actions, and observations, add agent name to the title + if isinstance(event, (SystemPromptEvent, ActionEvent, ObservationEvent)): + content = event.visualize + if not content.plain.strip(): + return None + + # Apply highlighting if configured + if self._highlight_patterns: + content = self._apply_highlighting(content) + + agent_name = self._format_agent_name(self._name) if self._name else "Agent" + + if isinstance(event, SystemPromptEvent): + title = ( + f"[bold {_SYSTEM_COLOR}]{agent_name} Agent System Prompt" + f"[/bold {_SYSTEM_COLOR}]" + ) + return Panel( + content, + title=title, + border_style=_SYSTEM_COLOR, + padding=_PANEL_PADDING, + expand=True, + ) + elif isinstance(event, ActionEvent): + # Check if action is None (non-executable) + if event.action is None: + title = ( + f"[bold {_ACTION_COLOR}]{agent_name} Agent Action " + f"(Not Executed)[/bold {_ACTION_COLOR}]" + ) + else: + title = ( + f"[bold {_ACTION_COLOR}]{agent_name} Agent Action" + f"[/bold {_ACTION_COLOR}]" + ) + return Panel( + content, + title=title, + subtitle=self._format_metrics_subtitle(), + border_style=_ACTION_COLOR, + padding=_PANEL_PADDING, + expand=True, + ) + else: # ObservationEvent + title = ( + f"[bold {_OBSERVATION_COLOR}]{agent_name} Agent Observation" + f"[/bold {_OBSERVATION_COLOR}]" + ) + return Panel( + content, + title=title, + border_style=_OBSERVATION_COLOR, + padding=_PANEL_PADDING, + expand=True, + ) + + # For all other event types, use the parent implementation + return super()._create_event_panel(event) + + def _create_message_event_panel(self, event: MessageEvent) -> Panel | None: + """ + Create a panel for a message event with delegation-specific + sender/receiver info. + + For user messages: + - If sender is set: "[Sender] Agent Message to [Agent] Agent" + - Otherwise: "User Message to [Agent] Agent" + + For agent messages: + - Derives recipient from event history (last user message sender) + - If recipient found: "[Agent] Agent Message to [Recipient] Agent" + - Otherwise: "Message from [Agent] Agent to User" + + Args: + event: The message event to visualize + + Returns: + A Rich Panel with delegation-aware title, or None if visualization fails + """ + content = event.visualize + if not content.plain.strip(): + return None + + assert event.llm_message is not None + + # Determine role color based on message role + if event.llm_message.role == "user": + role_color = "gold3" + elif event.llm_message.role == "assistant": + role_color = "blue" + else: + role_color = "white" + + # Build title with sender/recipient information for delegation + agent_name = self._format_agent_name(self._name) if self._name else "Agent" + + if event.llm_message.role == "user": + if event.sender: + # Message from another agent (via delegation) + sender_display = self._format_agent_name(event.sender) + title_text = ( + f"[bold {role_color}]{sender_display} Agent Message to " + f"{agent_name} Agent[/bold {role_color}]" + ) + else: + # Regular user message + title_text = ( + f"[bold {role_color}]User Message to " + f"{agent_name} Agent[/bold {role_color}]" + ) + else: + # For agent messages, derive recipient from last user message + recipient = None + if self._state: + for evt in reversed(self._state.events): + if isinstance(evt, MessageEvent) and evt.llm_message.role == "user": + recipient = evt.sender + break + + if recipient: + # Agent responding to another agent + recipient_display = self._format_agent_name(recipient) + title_text = ( + f"[bold {role_color}]{agent_name} Agent Message to " + f"{recipient_display} Agent[/bold {role_color}]" + ) + else: + # Agent responding to user + title_text = ( + f"[bold {role_color}]Message from {agent_name} Agent to User" + f"[/bold {role_color}]" + ) + + return Panel( + content, + title=title_text, + subtitle=self._format_metrics_subtitle(), + border_style=role_color, + padding=(1, 2), + expand=True, + ) diff --git a/tests/sdk/conversation/test_base_span_management.py b/tests/sdk/conversation/test_base_span_management.py index 184ee898ef..32f36858bd 100644 --- a/tests/sdk/conversation/test_base_span_management.py +++ b/tests/sdk/conversation/test_base_span_management.py @@ -40,7 +40,7 @@ def reject_pending_actions(self, reason: str = "User rejected the action") -> No def run(self) -> None: pass - def send_message(self, message: Any) -> None: + def send_message(self, message: Any, sender: str | None = None) -> None: pass def set_confirmation_policy(self, policy: Any) -> None: diff --git a/tests/tools/delegate/test_visualizer.py b/tests/tools/delegate/test_visualizer.py new file mode 100644 index 0000000000..6dc75a3dcb --- /dev/null +++ b/tests/tools/delegate/test_visualizer.py @@ -0,0 +1,205 @@ +"""Tests for the DelegationVisualizer class.""" + +import json +from unittest.mock import MagicMock + +from openhands.sdk.conversation.conversation_stats import ConversationStats +from openhands.sdk.event import ActionEvent, MessageEvent, ObservationEvent +from openhands.sdk.llm import Message, MessageToolCall, TextContent +from openhands.sdk.tool import Action, Observation +from openhands.tools.delegate import DelegationVisualizer + + +class MockDelegateAction(Action): + """Mock action for testing.""" + + command: str = "test command" + + +class MockDelegateObservation(Observation): + """Mock observation for testing.""" + + result: str = "test result" + + +def create_tool_call( + call_id: str, function_name: str, arguments: dict +) -> MessageToolCall: + """Helper to create a MessageToolCall.""" + return MessageToolCall( + id=call_id, + name=function_name, + arguments=json.dumps(arguments), + origin="completion", + ) + + +def test_delegation_visualizer_user_message_without_sender(): + """Test user message without sender shows 'User Message to [Agent] Agent'.""" + visualizer = DelegationVisualizer(name="MainAgent") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + mock_state.events = [] + visualizer.initialize(mock_state) + + user_message = Message(role="user", content=[TextContent(text="Hello")]) + user_event = MessageEvent(source="user", llm_message=user_message) + panel = visualizer._create_message_event_panel(user_event) + + assert panel is not None + title = str(panel.title) + assert "User Message to Main Agent Agent" in title + + +def test_delegation_visualizer_user_message_with_sender(): + """Test delegated message shows sender and receiver agent names.""" # noqa: E501 + visualizer = DelegationVisualizer(name="Lodging Expert") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + mock_state.events = [] + visualizer.initialize(mock_state) + + delegated_message = Message( + role="user", content=[TextContent(text="Task from parent")] + ) + delegated_event = MessageEvent( + source="user", llm_message=delegated_message, sender="Delegator" + ) + panel = visualizer._create_message_event_panel(delegated_event) + + assert panel is not None + title = str(panel.title) + assert "Delegator Agent Message to Lodging Expert Agent" in title + + +def test_delegation_visualizer_agent_response_to_user(): + """Test agent response to user shows 'Message from [Agent] Agent to User'.""" + visualizer = DelegationVisualizer(name="MainAgent") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + mock_state.events = [] + visualizer.initialize(mock_state) + + agent_message = Message( + role="assistant", content=[TextContent(text="Response to user")] + ) + response_event = MessageEvent(source="agent", llm_message=agent_message) + panel = visualizer._create_message_event_panel(response_event) + + assert panel is not None + title = str(panel.title) + assert "Message from Main Agent Agent to User" in title + + +def test_delegation_visualizer_agent_response_to_delegator(): + """Test sub-agent response to parent shows sender and receiver.""" # noqa: E501 + visualizer = DelegationVisualizer(name="Lodging Expert") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + + # Set up event history with delegated message + delegated_message = Message( + role="user", content=[TextContent(text="Task from parent")] + ) + delegated_event = MessageEvent( + source="user", llm_message=delegated_message, sender="Delegator" + ) + mock_state.events = [delegated_event] + visualizer.initialize(mock_state) + + # Sub-agent responds + agent_message = Message( + role="assistant", content=[TextContent(text="Response to delegator")] + ) + response_event = MessageEvent(source="agent", llm_message=agent_message) + panel = visualizer._create_message_event_panel(response_event) + + assert panel is not None + title = str(panel.title) + assert "Lodging Expert Agent Message to Delegator Agent" in title + + +def test_delegation_visualizer_formats_agent_names(): + """Test agent names are properly formatted (snake_case to Title Case).""" + visualizer = DelegationVisualizer(name="lodging_expert") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + + # Set up event history with delegated message from another agent + delegated_message = Message( + role="user", content=[TextContent(text="Task from parent")] + ) + delegated_event = MessageEvent( + source="user", llm_message=delegated_message, sender="main_delegator" + ) + mock_state.events = [delegated_event] + visualizer.initialize(mock_state) + + # Create panel for delegated message + panel = visualizer._create_message_event_panel(delegated_event) + assert panel is not None + title = str(panel.title) + assert "Main Delegator Agent Message to Lodging Expert Agent" in title + + # Sub-agent responds + agent_message = Message( + role="assistant", content=[TextContent(text="Response to delegator")] + ) + response_event = MessageEvent(source="agent", llm_message=agent_message) + panel = visualizer._create_message_event_panel(response_event) + + assert panel is not None + title = str(panel.title) + assert "Lodging Expert Agent Message to Main Delegator Agent" in title + + +def test_delegation_visualizer_action_event(): + """Test action event shows agent name in title.""" + visualizer = DelegationVisualizer(name="lodging_expert") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + mock_state.events = [] + visualizer.initialize(mock_state) + + # Create a proper action event + action = MockDelegateAction(command="search hotels") + tool_call = create_tool_call("call_123", "search", {"command": "search hotels"}) + action_event = ActionEvent( + thought=[TextContent(text="Searching for hotels")], + action=action, + tool_name="search", + tool_call_id="call_123", + tool_call=tool_call, + llm_response_id="response_456", + ) + + panel = visualizer._create_event_panel(action_event) + + assert panel is not None + title = str(panel.title) + assert "Lodging Expert Agent Action" in title + + +def test_delegation_visualizer_observation_event(): + """Test observation event shows agent name in title.""" + visualizer = DelegationVisualizer(name="main_delegator") + mock_state = MagicMock() + mock_state.stats = ConversationStats() + mock_state.events = [] + visualizer.initialize(mock_state) + + # Create a proper observation event + observation = MockDelegateObservation(result="Hotel search results") + observation_event = ObservationEvent( + source="environment", + observation=observation, + tool_name="search", + tool_call_id="call_123", + action_id="action_789", + ) + + panel = visualizer._create_event_panel(observation_event) + + assert panel is not None + title = str(panel.title) + assert "Main Delegator Agent Observation" in title