Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
37206de
Fix delegation visualization labels to show correct sender/receiver
openhands-agent Nov 10, 2025
792ff4c
Fix delegation visualization to show correct agent names
openhands-agent Nov 10, 2025
9d9377c
Improve visualizations for delegation with cleaner architecture
openhands-agent Nov 10, 2025
dd090cd
Simplify delegation visualization by deriving recipient in visualizer
openhands-agent Nov 10, 2025
7938989
Add DelegationVisualizer with agent name formatting
openhands-agent Nov 10, 2025
ede1f04
Remove dependency on _name for message events in default visualizer
openhands-agent Nov 10, 2025
ec29096
Remove name parameter from base and default visualizers
openhands-agent Nov 10, 2025
e049427
Show agent names in all event types (actions and observations)
openhands-agent Nov 10, 2025
51a554f
Revert "Show agent names in all event types (actions and observations)"
openhands-agent Nov 10, 2025
65faa7e
Fix type checking errors after revert
openhands-agent Nov 10, 2025
cd046ca
Add agent names to action and observation event titles in DelegationV…
openhands-agent Nov 10, 2025
1fe8889
Improve delegation message labels for better clarity
openhands-agent Nov 10, 2025
f89e115
Fix delegation visualization to show consistent 'Agent' suffix in all…
openhands-agent Nov 10, 2025
c8f4182
Move agent name formatting logic into DelegationVisualizer class
openhands-agent Nov 10, 2025
bbf2183
Remove duplicate _format_agent_name function from impl.py
openhands-agent Nov 10, 2025
4e86e98
Remove all name formatting from impl.py and enforce DelegationVisuali…
openhands-agent Nov 10, 2025
8c778b0
Allow delegation to work without visualizer
openhands-agent Nov 10, 2025
c00319c
Remove unnecessary skip_user_messages parameter from DelegationVisual…
openhands-agent Nov 10, 2025
d8b7b58
Revert "Remove unnecessary skip_user_messages parameter from Delegati…
openhands-agent Nov 10, 2025
64fbecd
Merge branch 'main' into openhands/fix-delegation-visualization
simonrosenberg Nov 10, 2025
8ed9293
Fix merge conflict: remove _name reference from CondensationRequest h…
openhands-agent Nov 10, 2025
06a9076
Remove test_message_event_title_with_sender from base visualizer tests
openhands-agent Nov 10, 2025
b75ae23
simplify
simonrosenberg Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions examples/01_standalone_sdk/25_agent_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -51,7 +50,7 @@
conversation = Conversation(
agent=main_agent,
workspace=cwd,
visualizer=DefaultConversationVisualizer(name="Delegator"),
visualizer=DelegationVisualizer(name="Delegator"),
)

task_message = (
Expand Down
9 changes: 0 additions & 9 deletions examples/01_standalone_sdk/26_custom_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}...")
Expand Down
13 changes: 11 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", (
Expand All @@ -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
)
Expand Down
25 changes: 3 additions & 22 deletions openhands-sdk/openhands/sdk/conversation/visualizer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,21 +28,10 @@ class ConversationVisualizerBase(ABC):
Conversation will then calls `MyVisualizer()` followed by `initialize(state)`
"""

_name: str | None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there anyway that we are not breaking this?

removing this is a breaking changes. But i do see doing so would simplify our code and hopefully have minimal impact to users

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have any other breaking change for 1.0.1 ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean if you know, off-hand. Don't worry if you don't! I'll find out automatically

Copy link
Collaborator Author

@simonrosenberg simonrosenberg Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this _name was introduce solely for delegation visualization. So I think it does make sense to remove it so the default viz remains as simple as possible... Cf other comment: no need for init anymore in minimal custom visualizer

_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
Expand All @@ -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."""
Expand Down
76 changes: 23 additions & 53 deletions openhands-sdk/openhands/sdk/conversation/visualizer/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,20 @@ 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",
"Thought:": "bold green"}
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 {}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions openhands-sdk/openhands/sdk/event/llm_convertible/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please let me try to see if I got this right, so right now, on the PR, we save a sender in the MessageEvent, although

  • we don't tell the LLM the sender
  • we don't use it in visualize

🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that's a bit sketchy!
We could use it in the default visualize so it makes more sense

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:
Expand Down
2 changes: 2 additions & 0 deletions openhands-tools/openhands/tools/delegate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
DelegateTool,
)
from openhands.tools.delegate.impl import DelegateExecutor
from openhands.tools.delegate.visualizer import DelegationVisualizer


__all__ = [
"DelegateAction",
"DelegateObservation",
"DelegateExecutor",
"DelegateTool",
"DelegationVisualizer",
]
Loading
Loading