Skip to content

Commit 3b8b501

Browse files
refactor the system to use Event Types (#44)
Co-authored-by: openhands <[email protected]>
1 parent 1a1835a commit 3b8b501

File tree

19 files changed

+512
-259
lines changed

19 files changed

+512
-259
lines changed

examples/hello_world.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44

55
from openhands.core import (
66
LLM,
7-
ActionBase,
87
CodeActAgent,
98
Conversation,
10-
ConversationEventType,
9+
EventType,
1110
LLMConfig,
11+
LLMConvertibleEvent,
1212
Message,
13-
ObservationBase,
1413
TextContent,
1514
Tool,
1615
get_logger,
@@ -28,11 +27,13 @@
2827
# Configure LLM
2928
api_key = os.getenv("LITELLM_API_KEY")
3029
assert api_key is not None, "LITELLM_API_KEY environment variable is not set."
31-
llm = LLM(config=LLMConfig(
32-
model="litellm_proxy/anthropic/claude-sonnet-4-20250514",
33-
base_url="https://llm-proxy.eval.all-hands.dev",
34-
api_key=SecretStr(api_key),
35-
))
30+
llm = LLM(
31+
config=LLMConfig(
32+
model="litellm_proxy/anthropic/claude-sonnet-4-20250514",
33+
base_url="https://llm-proxy.eval.all-hands.dev",
34+
api_key=SecretStr(api_key),
35+
)
36+
)
3637

3738
# Tools
3839
cwd = os.getcwd()
@@ -47,15 +48,13 @@
4748
agent = CodeActAgent(llm=llm, tools=tools)
4849

4950
llm_messages = [] # collect raw LLM messages
50-
def conversation_callback(event: ConversationEventType):
51-
# print all the actions
52-
if isinstance(event, ActionBase):
53-
logger.info(f"Found a conversation action: {event}")
54-
elif isinstance(event, ObservationBase):
55-
logger.info(f"Found a conversation observation: {event}")
56-
elif isinstance(event, Message):
57-
logger.info(f"Found a conversation message: {str(event)[:200]}...")
58-
llm_messages.append(event.model_dump())
51+
52+
53+
def conversation_callback(event: EventType):
54+
logger.info(f"Found a conversation message: {str(event)[:200]}...")
55+
if isinstance(event, LLMConvertibleEvent):
56+
llm_messages.append(event.to_llm_message())
57+
5958

6059
conversation = Conversation(agent=agent, callbacks=[conversation_callback])
6160

@@ -67,7 +66,7 @@ def conversation_callback(event: ConversationEventType):
6766
)
6867
conversation.run()
6968

70-
print("="*100)
69+
print("=" * 100)
7170
print("Conversation finished. Got the following LLM messages:")
7271
for i, message in enumerate(llm_messages):
7372
print(f"Message {i}: {str(message)[:200]}")

openhands/core/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from .agent import AgentBase, CodeActAgent
44
from .config import LLMConfig, MCPConfig
5-
from .conversation import Conversation, ConversationCallbackType, ConversationEventType
5+
from .conversation import Conversation, ConversationCallbackType
6+
from .event import EventBase, EventType, LLMConvertibleEvent
67
from .llm import LLM, ImageContent, Message, TextContent
78
from .logger import get_logger
89
from .tool import ActionBase, ObservationBase, Tool
@@ -28,6 +29,8 @@
2829
"get_logger",
2930
"Conversation",
3031
"ConversationCallbackType",
31-
"ConversationEventType",
32+
"EventType",
33+
"EventBase",
34+
"LLMConvertibleEvent",
3235
"__version__",
3336
]

openhands/core/agent/base.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from openhands.core.context.env_context import EnvContext
55
from openhands.core.conversation import ConversationCallbackType, ConversationState
6-
from openhands.core.llm import LLM, Message
6+
from openhands.core.llm import LLM
77
from openhands.core.logger import get_logger
88
from openhands.core.tool import Tool
99

@@ -60,15 +60,11 @@ def env_context(self) -> EnvContext | None:
6060
def init_state(
6161
self,
6262
state: ConversationState,
63-
initial_user_message: Message | None = None,
64-
on_event: ConversationCallbackType | None = None,
63+
on_event: ConversationCallbackType,
6564
) -> None:
6665
"""Initialize the empty conversation state to prepare the agent for user messages.
6766
68-
Typically this involves:
69-
1. Adding system message
70-
2. Adding initial user messages with environment context
71-
(e.g., microagents, current working dir, etc)
67+
Typically this involves adding system message
7268
7369
NOTE: state will be mutated in-place.
7470
"""
@@ -78,7 +74,7 @@ def init_state(
7874
def step(
7975
self,
8076
state: ConversationState,
81-
on_event: ConversationCallbackType | None = None,
77+
on_event: ConversationCallbackType,
8278
) -> None:
8379
"""Taking a step in the conversation.
8480
Lines changed: 73 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
import os
3-
from typing import Callable
3+
from typing import cast
44

55
from litellm.types.utils import (
66
ChatCompletionMessageToolCall,
@@ -12,6 +12,7 @@
1212

1313
from openhands.core.context import EnvContext, PromptManager
1414
from openhands.core.conversation import ConversationCallbackType, ConversationState
15+
from openhands.core.event import ActionEvent, AgentErrorEvent, LLMConvertibleEvent, MessageEvent, ObservationEvent, SystemPromptEvent
1516
from openhands.core.llm import LLM, Message, TextContent, get_llm_metadata
1617
from openhands.core.logger import get_logger
1718
from openhands.core.tool import BUILT_IN_TOOLS, ActionBase, FinishTool, ObservationBase, Tool
@@ -22,7 +23,6 @@
2223
logger = get_logger(__name__)
2324

2425

25-
2626
class CodeActAgent(AgentBase):
2727
def __init__(
2828
self,
@@ -45,43 +45,25 @@ def __init__(
4545
def init_state(
4646
self,
4747
state: ConversationState,
48-
initial_user_message: Message | None = None,
49-
on_event: ConversationCallbackType | None = None,
48+
on_event: ConversationCallbackType,
5049
) -> None:
5150
# TODO(openhands): we should add test to test this init_state will actually modify state in-place
52-
messages = state.history.messages
51+
messages = [e.to_llm_message() for e in state.events]
5352
if len(messages) == 0:
5453
# Prepare system message
55-
sys_msg = Message(role="system", content=[self.system_message])
56-
messages.append(sys_msg)
57-
if on_event:
58-
on_event(sys_msg)
59-
if initial_user_message is None:
60-
raise ValueError("initial_user_message must be provided in init_state for CodeActAgent")
61-
62-
# Prepare user message
63-
content = initial_user_message.content
64-
# TODO: think about this - we might want to handle this outside Agent but inside Conversation (e.g., in send_messages)
65-
# downside of handling them inside Conversation would be: conversation don't have access
66-
# to *any* action execution runtime information
67-
if self.env_context:
68-
initial_env_context: list[TextContent] = self.env_context.render(self.prompt_manager)
69-
content += initial_env_context
70-
user_msg = Message(role="user", content=content)
71-
messages.append(user_msg)
72-
if on_event:
73-
on_event(user_msg)
74-
if self.env_context and self.env_context.activated_microagents:
75-
for microagent in self.env_context.activated_microagents:
76-
state.history.microagent_activations.append((microagent.name, len(messages) - 1))
54+
event = SystemPromptEvent(source="agent", system_prompt=self.system_message, tools=[t.to_openai_tool() for t in self.tools.values()])
55+
# TODO: maybe we should combine this into on_event?
56+
state.events.append(event)
57+
on_event(event)
7758

7859
def step(
7960
self,
8061
state: ConversationState,
81-
on_event: ConversationCallbackType | None = None,
62+
on_event: ConversationCallbackType,
8263
) -> None:
8364
# Get LLM Response (Action)
84-
_messages = self.llm.format_messages_for_llm(state.history.messages)
65+
llm_convertible_events = cast(list[LLMConvertibleEvent], [e for e in state.events if isinstance(e, LLMConvertibleEvent)])
66+
_messages = self.llm.format_messages_for_llm(LLMConvertibleEvent.events_to_messages(llm_convertible_events))
8567
logger.debug(f"Sending messages to LLM: {json.dumps(_messages, indent=2)}")
8668
response: ModelResponse = self.llm.completion(
8769
messages=_messages,
@@ -90,30 +72,54 @@ def step(
9072
)
9173
assert len(response.choices) == 1 and isinstance(response.choices[0], Choices)
9274
llm_message: LiteLLMMessage = response.choices[0].message # type: ignore
93-
9475
message = Message.from_litellm_message(llm_message)
95-
state.history.messages.append(message)
96-
if on_event:
97-
on_event(message)
9876

9977
if message.tool_calls and len(message.tool_calls) > 0:
10078
tool_call: ChatCompletionMessageToolCall
79+
if any(tc.type != "function" for tc in message.tool_calls):
80+
logger.warning("LLM returned tool calls but some are not of type 'function' - ignoring those")
81+
10182
tool_calls = [tool_call for tool_call in message.tool_calls if tool_call.type == "function"]
10283
assert len(tool_calls) > 0, "LLM returned tool calls but none are of type 'function'"
103-
for tool_call in tool_calls:
104-
self._handle_tool_call(tool_call, state, on_event)
84+
if not all(isinstance(c, TextContent) for c in message.content):
85+
logger.warning("LLM returned tool calls but message content is not all TextContent - ignoring non-text content")
86+
87+
# Generate unique batch ID for this LLM response
88+
thought_content = [c for c in message.content if isinstance(c, TextContent)]
89+
90+
action_events = []
91+
for i, tool_call in enumerate(tool_calls):
92+
action_event = self._get_action_events(
93+
state,
94+
tool_call,
95+
llm_response_id=response.id,
96+
on_event=on_event,
97+
thought=thought_content if i == 0 else [], # Only first gets thought
98+
)
99+
if action_event is None:
100+
continue
101+
action_events.append(action_event)
102+
state.events.append(action_event)
103+
104+
for action_event in action_events:
105+
self._execute_action_events(state, action_event, on_event=on_event)
105106
else:
106107
logger.info("LLM produced a message response - awaits user input")
107108
state.agent_finished = True
109+
msg_event = MessageEvent(source="agent", llm_message=message)
110+
state.events.append(msg_event)
111+
on_event(msg_event)
108112

109-
def _handle_tool_call(
113+
def _get_action_events(
110114
self,
111-
tool_call: ChatCompletionMessageToolCall,
112115
state: ConversationState,
113-
on_event: Callable[[Message | ActionBase | ObservationBase], None] | None = None,
114-
) -> None:
116+
tool_call: ChatCompletionMessageToolCall,
117+
llm_response_id: str,
118+
on_event: ConversationCallbackType,
119+
thought: list[TextContent] = [],
120+
) -> ActionEvent | None:
115121
"""Handle tool calls from the LLM.
116-
122+
117123
NOTE: state will be mutated in-place.
118124
"""
119125
assert tool_call.type == "function"
@@ -124,35 +130,47 @@ def _handle_tool_call(
124130
if tool is None:
125131
err = f"Tool '{tool_name}' not found. Available: {list(self.tools.keys())}"
126132
logger.error(err)
127-
state.history.messages.append(Message(role="user", content=[TextContent(text=err)]))
133+
event = AgentErrorEvent(error=err)
134+
state.events.append(event)
135+
on_event(event)
128136
state.agent_finished = True
129137
return
130138

131139
# Validate arguments
132140
try:
133141
action: ActionBase = tool.action_type.model_validate(json.loads(tool_call.function.arguments))
134-
if on_event:
135-
on_event(action)
136142
except (json.JSONDecodeError, ValidationError) as e:
137143
err = f"Error validating args {tool_call.function.arguments} for tool '{tool.name}': {e}"
138-
logger.error(err)
139-
state.history.messages.append(Message(role="tool", name=tool.name, tool_call_id=tool_call.id, content=[TextContent(text=err)]))
144+
event = AgentErrorEvent(error=err)
145+
state.events.append(event)
146+
on_event(event)
140147
return
141148

149+
# Create one ActionEvent per action
150+
action_event = ActionEvent(action=action, thought=thought, tool_name=tool.name, tool_call_id=tool_call.id, tool_call=tool_call, llm_response_id=llm_response_id)
151+
on_event(action_event)
152+
return action_event
153+
154+
def _execute_action_events(self, state: ConversationState, action_event: ActionEvent, on_event: ConversationCallbackType):
155+
"""Execute action events and update the conversation state.
156+
157+
It will call the tool's executor and update the state & call callback fn with the observation.
158+
"""
159+
tool = self.tools.get(action_event.tool_name, None)
160+
if tool is None:
161+
raise RuntimeError(f"Tool '{action_event.tool_name}' not found. This should not happen as it was checked earlier.")
162+
142163
# Execute actions!
143164
if tool.executor is None:
144165
raise RuntimeError(f"Tool '{tool.name}' has no executor")
145-
observation: ObservationBase = tool.executor(action)
146-
tool_msg = Message(
147-
role="tool",
148-
name=tool.name,
149-
tool_call_id=tool_call.id,
150-
content=[TextContent(text=observation.agent_observation)],
151-
)
152-
state.history.messages.append(tool_msg)
153-
if on_event:
154-
on_event(observation)
166+
observation: ObservationBase = tool.executor(action_event.action)
167+
assert isinstance(observation, ObservationBase), f"Tool '{tool.name}' executor must return an ObservationBase"
168+
169+
obs_event = ObservationEvent(observation=observation, action_id=action_event.id, tool_name=tool.name, tool_call_id=action_event.tool_call.id)
170+
on_event(obs_event)
155171

156172
# Set conversation state
157173
if tool.name == FinishTool.name:
158174
state.agent_finished = True
175+
state.events.append(obs_event)
176+
return obs_event

openhands/core/context/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
RepositoryInfo,
55
RuntimeInfo,
66
)
7-
from .history import AgentHistory
87
from .message_context import MessageContext
98
from .microagents import (
109
BaseMicroagent,
@@ -32,5 +31,4 @@
3231
"MicroagentType",
3332
"MicroagentKnowledge",
3433
"load_microagents_from_dir",
35-
"AgentHistory",
3634
]

openhands/core/context/history.py

Lines changed: 0 additions & 22 deletions
This file was deleted.

openhands/core/context/manager.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
21
class LLMContextManager:
3-
"""Context manager for messages we send to LLM.
4-
5-
6-
"""
2+
"""Context manager for messages we send to LLM."""
3+
74
def __init__(self) -> None:
85
pass

openhands/core/context/prompt_extension

Whitespace-only changes.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from .conversation import Conversation
22
from .state import ConversationState
3-
from .types import ConversationCallbackType, ConversationEventType
3+
from .types import ConversationCallbackType
44
from .visualizer import ConversationVisualizer
55

66

7-
__all__ = ["Conversation", "ConversationState", "ConversationCallbackType", "ConversationEventType", "ConversationVisualizer"]
7+
__all__ = ["Conversation", "ConversationState", "ConversationCallbackType", "ConversationVisualizer"]

0 commit comments

Comments
 (0)