Skip to content

Commit f97f0ae

Browse files
refactor repo by creating context/; move looping logic into conversation.py (#36)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 24ac006 commit f97f0ae

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+496
-421
lines changed

.openhands/microagents/repo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,5 @@ This project contains my tasks of completely refactor [OpenHands](https://github
146146
- You can run pytest with `uv run pytest`
147147
- Don't write TOO MUCH test, you should write just enough to cover edge cases.
148148
- AFTER you edit ONE file, you should run pre-commit hook on that file via `uv run pre-commit run --files [filepath]` to make sure you didn't break it.
149+
- Avoid hacky trick like `sys.path.insert` when resolving package dependency
149150
</NOTE>

examples/hello_world.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
Conversation,
99
LLMConfig,
1010
Message,
11-
OpenHandsConfig,
1211
TextContent,
1312
Tool,
1413
get_logger,
@@ -26,14 +25,11 @@
2625
# Configure LLM
2726
api_key = os.getenv("LITELLM_API_KEY")
2827
assert api_key is not None, "LITELLM_API_KEY environment variable is not set."
29-
config = OpenHandsConfig(
30-
llm=LLMConfig(
31-
model="litellm_proxy/anthropic/claude-sonnet-4-20250514",
32-
base_url="https://llm-proxy.eval.all-hands.dev",
33-
api_key=SecretStr(api_key),
34-
)
35-
)
36-
llm = LLM(config=config.llm)
28+
llm = LLM(config=LLMConfig(
29+
model="litellm_proxy/anthropic/claude-sonnet-4-20250514",
30+
base_url="https://llm-proxy.eval.all-hands.dev",
31+
api_key=SecretStr(api_key),
32+
))
3733

3834
# Tools
3935
cwd = os.getcwd()
@@ -54,3 +50,4 @@
5450
content=[TextContent(text="Hello! Can you create a new Python file named hello.py that prints 'Hello, World!'?")],
5551
)
5652
)
53+
conversation.run()

openhands/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
1+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)

openhands/core/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from importlib.metadata import PackageNotFoundError, version
22

3-
from .agenthub import AgentBase, CodeActAgent
4-
from .config import LLMConfig, MCPConfig, OpenHandsConfig
3+
from .agent import AgentBase, CodeActAgent
4+
from .config import LLMConfig, MCPConfig
55
from .conversation import Conversation
66
from .llm import LLM, ImageContent, Message, TextContent
77
from .logger import get_logger
@@ -23,7 +23,6 @@
2323
"CodeActAgent",
2424
"ActionBase",
2525
"ObservationBase",
26-
"OpenHandsConfig",
2726
"LLMConfig",
2827
"MCPConfig",
2928
"get_logger",
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
from .agent import AgentBase
1+
from .base import AgentBase
22
from .codeact_agent import CodeActAgent
3-
from .history import AgentHistory
43

54

65
__all__ = [
76
"CodeActAgent",
87
"AgentBase",
9-
"AgentHistory",
108
]

openhands/core/agent/base.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from abc import ABC, abstractmethod
2+
from types import MappingProxyType
3+
4+
from openhands.core.context.env_context import EnvContext
5+
from openhands.core.conversation import ConversationCallbackType, ConversationState
6+
from openhands.core.llm import LLM
7+
from openhands.core.logger import get_logger
8+
from openhands.core.tool import Tool
9+
10+
11+
logger = get_logger(__name__)
12+
13+
14+
class AgentBase(ABC):
15+
def __init__(
16+
self,
17+
llm: LLM,
18+
tools: list[Tool],
19+
env_context: EnvContext | None = None,
20+
) -> None:
21+
"""Initializes a new instance of the Agent class.
22+
23+
Agent should be Stateless: every step only relies on:
24+
1. input ConversationState
25+
2. LLM/tools/env_context that were given in __init__
26+
"""
27+
self._llm = llm
28+
self._env_context = env_context
29+
30+
# Load tools into an immutable dict
31+
_tools_map = {}
32+
for tool in tools:
33+
if tool.name in _tools_map:
34+
raise ValueError(f"Duplicate tool name: {tool.name}")
35+
logger.debug(f"Registering tool: {tool}")
36+
_tools_map[tool.name] = tool
37+
self._tools = MappingProxyType(_tools_map)
38+
39+
@property
40+
def name(self) -> str:
41+
"""Returns the name of the Agent."""
42+
return self.__class__.__name__
43+
44+
@property
45+
def llm(self) -> LLM:
46+
"""Returns the LLM instance used by the Agent."""
47+
return self._llm
48+
49+
@property
50+
def tools(self) -> MappingProxyType[str, Tool]:
51+
"""Returns an immutable mapping of available tools from name."""
52+
return self._tools
53+
54+
@property
55+
def env_context(self) -> EnvContext | None:
56+
"""Returns the environment context used by the Agent."""
57+
return self._env_context
58+
59+
@abstractmethod
60+
def init_state(
61+
self,
62+
state: ConversationState,
63+
on_event: ConversationCallbackType | None = None,
64+
) -> ConversationState:
65+
"""Initialize the empty conversation state to prepare the agent for user messages.
66+
67+
Typically this involves:
68+
1. Adding system message
69+
2. Adding initial user messages with environment context
70+
(e.g., microagents, current working dir, etc)
71+
"""
72+
raise NotImplementedError("Subclasses must implement this method.")
73+
74+
@abstractmethod
75+
def step(
76+
self,
77+
state: ConversationState,
78+
on_event: ConversationCallbackType | None = None,
79+
) -> ConversationState:
80+
"""Taking a step in the conversation.
81+
82+
Typically this involves:
83+
1. Making a LLM call
84+
2. Executing the tool
85+
3. Updating the conversation state with
86+
LLM calls (role="assistant") and tool results (role="tool")
87+
4.1 If conversation is finished, set state.agent_finished flag
88+
4.2 Otherwise, just return, Conversation will kick off the next step
89+
"""
90+
raise NotImplementedError("Subclasses must implement this method.")
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import json
2+
import os
3+
from typing import Callable
4+
5+
from litellm.types.utils import (
6+
ChatCompletionMessageToolCall,
7+
Choices,
8+
Message as LiteLLMMessage,
9+
ModelResponse,
10+
)
11+
from pydantic import Field, ValidationError
12+
13+
from openhands.core.context import EnvContext, PromptManager
14+
from openhands.core.conversation import ConversationCallbackType, ConversationState
15+
from openhands.core.llm import LLM, Message, TextContent, get_llm_metadata
16+
from openhands.core.logger import get_logger
17+
from openhands.core.tool import ActionBase, ObservationBase, Tool, ToolAnnotations
18+
19+
from ..base import AgentBase
20+
21+
22+
logger = get_logger(__name__)
23+
24+
"""Finish tool implementation."""
25+
26+
27+
class FinishAction(ActionBase):
28+
message: str = Field(description="Final message to send to the user.")
29+
30+
31+
TOOL_DESCRIPTION = """Signals the completion of the current task or conversation.
32+
33+
Use this tool when:
34+
- You have successfully completed the user's requested task
35+
- You cannot proceed further due to technical limitations or missing information
36+
37+
The message should include:
38+
- A clear summary of actions taken and their results
39+
- Any next steps for the user
40+
- Explanation if you're unable to complete the task
41+
- Any follow-up questions if more information is needed
42+
"""
43+
44+
45+
FINISH_TOOL = Tool(
46+
name="finish",
47+
input_schema=FinishAction,
48+
description=TOOL_DESCRIPTION,
49+
annotations=ToolAnnotations(
50+
title="finish",
51+
readOnlyHint=True,
52+
destructiveHint=False,
53+
idempotentHint=True,
54+
openWorldHint=False,
55+
),
56+
)
57+
58+
59+
class CodeActAgent(AgentBase):
60+
def __init__(
61+
self,
62+
llm: LLM,
63+
tools: list[Tool],
64+
env_context: EnvContext | None = None,
65+
system_prompt_filename: str = "system_prompt.j2",
66+
cli_mode: bool = True,
67+
) -> None:
68+
assert FINISH_TOOL not in tools, "Finish tool is automatically included and should not be provided."
69+
super().__init__(llm=llm, tools=tools + [FINISH_TOOL], env_context=env_context)
70+
self.prompt_manager = PromptManager(
71+
prompt_dir=os.path.join(os.path.dirname(__file__), "prompts"),
72+
system_prompt_filename=system_prompt_filename,
73+
)
74+
self.system_message: TextContent = self.prompt_manager.get_system_message(cli_mode=cli_mode)
75+
self.max_iterations: int = 10
76+
77+
def init_state(
78+
self,
79+
state: ConversationState,
80+
on_event: ConversationCallbackType | None = None,
81+
) -> ConversationState:
82+
# TODO(openhands): we should add test to test this init_state will actually modify state in-place
83+
messages = state.history.messages
84+
if len(messages) == 0:
85+
sys_msg = Message(role="system", content=[self.system_message])
86+
messages.append(sys_msg)
87+
if on_event:
88+
on_event(sys_msg)
89+
content = state.history.messages[-1].content
90+
if self.env_context:
91+
initial_env_context: list[TextContent] = self.env_context.render(self.prompt_manager)
92+
content += initial_env_context
93+
user_msg = Message(role="user", content=content)
94+
messages.append(user_msg)
95+
if on_event:
96+
on_event(user_msg)
97+
if self.env_context and self.env_context.activated_microagents:
98+
for microagent in self.env_context.activated_microagents:
99+
state.history.microagent_activations.append((microagent.name, len(messages) - 1))
100+
return state
101+
102+
def step(
103+
self,
104+
state: ConversationState,
105+
on_event: ConversationCallbackType | None = None,
106+
) -> ConversationState:
107+
# Get LLM Response (Action)
108+
_messages = self.llm.format_messages_for_llm(state.history.messages)
109+
logger.debug(f"Sending messages to LLM: {json.dumps(_messages, indent=2)}")
110+
response: ModelResponse = self.llm.completion(
111+
messages=_messages,
112+
tools=[tool.to_openai_tool() for tool in self.tools.values()],
113+
extra_body={"metadata": get_llm_metadata(model_name=self.llm.config.model, agent_name=self.name)},
114+
)
115+
assert len(response.choices) == 1 and isinstance(response.choices[0], Choices)
116+
llm_message: LiteLLMMessage = response.choices[0].message # type: ignore
117+
118+
message = Message.from_litellm_message(llm_message)
119+
state.history.messages.append(message)
120+
if on_event:
121+
on_event(message)
122+
123+
if message.tool_calls and len(message.tool_calls) > 0:
124+
tool_call: ChatCompletionMessageToolCall
125+
tool_calls = [tool_call for tool_call in message.tool_calls if tool_call.type == "function"]
126+
assert len(tool_calls) > 0, "LLM returned tool calls but none are of type 'function'"
127+
for tool_call in tool_calls:
128+
state = self._handle_tool_call(tool_call, state, on_event)
129+
else:
130+
logger.info("LLM produced a message response - awaits user input")
131+
state.agent_finished = True
132+
return state
133+
134+
def _handle_tool_call(
135+
self,
136+
tool_call: ChatCompletionMessageToolCall,
137+
state: ConversationState,
138+
on_event: Callable[[Message | ActionBase | ObservationBase], None] | None = None,
139+
) -> ConversationState:
140+
assert tool_call.type == "function"
141+
tool_name = tool_call.function.name
142+
assert tool_name is not None, "Tool call must have a name"
143+
tool = self.tools.get(tool_name, None)
144+
# Handle non-existing tools
145+
if tool is None:
146+
err = f"Tool '{tool_name}' not found. Available: {list(self.tools.keys())}"
147+
logger.error(err)
148+
state.history.messages.append(Message(role="user", content=[TextContent(text=err)]))
149+
state.agent_finished = True
150+
return state
151+
152+
# Validate arguments
153+
try:
154+
action: ActionBase = tool.action_type.model_validate(json.loads(tool_call.function.arguments))
155+
if on_event:
156+
on_event(action)
157+
except (json.JSONDecodeError, ValidationError) as e:
158+
err = f"Error validating args {tool_call.function.arguments} for tool '{tool.name}': {e}"
159+
logger.error(err)
160+
state.history.messages.append(Message(role="tool", name=tool.name, tool_call_id=tool_call.id, content=[TextContent(text=err)]))
161+
return state
162+
163+
# Early return for finish action (no need for tool execution)
164+
if isinstance(action, FinishAction):
165+
assert tool.name == FINISH_TOOL.name, "FinishAction must be used with the finish tool"
166+
state.agent_finished = True
167+
return state
168+
169+
# Execute actions!
170+
if tool.executor is None:
171+
raise RuntimeError(f"Tool '{tool.name}' has no executor")
172+
observation: ObservationBase = tool.executor(action)
173+
tool_msg = Message(
174+
role="tool",
175+
name=tool.name,
176+
tool_call_id=tool_call.id,
177+
content=[TextContent(text=observation.agent_observation)],
178+
)
179+
state.history.messages.append(tool_msg)
180+
if on_event:
181+
on_event(observation)
182+
return state

0 commit comments

Comments
 (0)