diff --git a/evaluation/benchmarks/swe_bench/run_infer.py b/evaluation/benchmarks/swe_bench/run_infer.py index dfb71464159c..b0f2c399096e 100644 --- a/evaluation/benchmarks/swe_bench/run_infer.py +++ b/evaluation/benchmarks/swe_bench/run_infer.py @@ -32,6 +32,7 @@ codeact_user_response, codex_user_response, opencode_user_response, + swe_agent_user_response, get_default_sandbox_config_for_eval, get_metrics, get_openhands_config_for_eval, @@ -108,6 +109,7 @@ def set_dataset_type(dataset_name: str) -> str: 'CodeActAgent': codeact_user_response, 'OpenCodeAgent': opencode_user_response, 'CodexAgent': codex_user_response, + 'SWEAgent': swe_agent_user_response, } diff --git a/openhands/agenthub/__init__.py b/openhands/agenthub/__init__.py index 73bb54f2e6f9..0539f75a2299 100644 --- a/openhands/agenthub/__init__.py +++ b/openhands/agenthub/__init__.py @@ -11,6 +11,7 @@ loc_agent, opencode_agent, readonly_agent, + swe_agent, visualbrowsing_agent, ) from openhands.controller.agent import Agent # noqa: E402 @@ -25,4 +26,5 @@ 'loc_agent', 'opencode_agent', 'codex_agent', + 'swe_agent', ] diff --git a/openhands/agenthub/swe_agent/__init__.py b/openhands/agenthub/swe_agent/__init__.py new file mode 100644 index 000000000000..0e8364420f1f --- /dev/null +++ b/openhands/agenthub/swe_agent/__init__.py @@ -0,0 +1,4 @@ +from openhands.agenthub.swe_agent.swe_agent import SWEAgent +from openhands.controller.agent import Agent + +Agent.register('SWEAgent', SWEAgent) diff --git a/openhands/agenthub/swe_agent/function_calling.py b/openhands/agenthub/swe_agent/function_calling.py new file mode 100644 index 000000000000..7b859a7723c8 --- /dev/null +++ b/openhands/agenthub/swe_agent/function_calling.py @@ -0,0 +1,198 @@ +"""Function calling implementation for SWE-Agent. + +Maps SWE-Agent tool calls (bash, str_replace_editor, submit) to OpenHands actions. +""" + +import json + +from litellm import ModelResponse + +from openhands.agenthub.codeact_agent.function_calling import combine_thought +from openhands.agenthub.swe_agent.tools.bash import SWE_AGENT_BASH_TOOL_NAME +from openhands.agenthub.swe_agent.tools.str_replace_editor import ( + SWE_AGENT_STR_REPLACE_EDITOR_TOOL_NAME, + StrReplaceEditorTool, +) +from openhands.agenthub.swe_agent.tools.submit import SWE_AGENT_SUBMIT_TOOL_NAME +from openhands.core.exceptions import ( + FunctionCallNotExistsError, + FunctionCallValidationError, + LLMContextWindowExceedError, +) +from openhands.core.logger import openhands_logger as logger +from openhands.events.action import ( + Action, + AgentFinishAction, + CmdRunAction, + FileEditAction, + FileReadAction, + MessageAction, + ValidationFailureAction, +) +from openhands.events.action.mcp import MCPAction +from openhands.events.event import FileEditSource, FileReadSource +from openhands.events.tool import ToolCallMetadata + + +def response_to_actions( + response: ModelResponse, mcp_tool_names: list[str] | None = None +) -> list[Action]: + actions: list[Action] = [] + assert len(response.choices) == 1 + choice = response.choices[0] + assistant_msg = choice.message + + has_content = assistant_msg.content is not None + has_tool_calls = hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls + + if not has_content and not has_tool_calls: + raise LLMContextWindowExceedError( + 'LLM returned empty response with no content and no tool calls.' + ) + + if hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls: + # Extract thought from content + thought = '' + if isinstance(assistant_msg.content, str): + thought = assistant_msg.content + elif isinstance(assistant_msg.content, list): + for msg in assistant_msg.content: + if msg['type'] == 'text': + thought += msg['text'] + + for i, tool_call in enumerate(assistant_msg.tool_calls): + action: Action + logger.debug(f'SWE-Agent tool call: {tool_call}') + + try: + try: + arguments = json.loads(tool_call.function.arguments) + except json.decoder.JSONDecodeError as e: + raise FunctionCallValidationError( + f'Failed to parse tool call arguments: {tool_call.function.arguments}' + ) from e + + # ================================================ + # Bash tool + # ================================================ + if tool_call.function.name == SWE_AGENT_BASH_TOOL_NAME: + if 'command' not in arguments: + raise FunctionCallValidationError( + f'Missing required argument "command" in tool call {tool_call.function.name}' + ) + action = CmdRunAction(command=arguments['command']) + + # ================================================ + # str_replace_editor tool + # ================================================ + elif tool_call.function.name == SWE_AGENT_STR_REPLACE_EDITOR_TOOL_NAME: + if 'command' not in arguments: + raise FunctionCallValidationError( + f'Missing required argument "command" in tool call {tool_call.function.name}' + ) + if 'path' not in arguments: + raise FunctionCallValidationError( + f'Missing required argument "path" in tool call {tool_call.function.name}' + ) + + path = arguments['path'] + command = arguments['command'] + other_kwargs = { + k: v + for k, v in arguments.items() + if k not in ['command', 'path'] + } + + if command == 'view': + action = FileReadAction( + path=path, + impl_source=FileReadSource.OH_ACI, + view_range=other_kwargs.get('view_range', None), + ) + else: + if 'view_range' in other_kwargs: + other_kwargs.pop('view_range') + + # Filter to valid str_replace_editor params + valid_params = set( + StrReplaceEditorTool['function']['parameters'][ + 'properties' + ].keys() + ) + valid_kwargs_for_editor = { + k: v + for k, v in other_kwargs.items() + if k in valid_params + } + + action = FileEditAction( + path=path, + command=command, + impl_source=FileEditSource.OH_ACI, + **valid_kwargs_for_editor, + ) + + # ================================================ + # Submit tool + # ================================================ + elif tool_call.function.name == SWE_AGENT_SUBMIT_TOOL_NAME: + action = AgentFinishAction() + + # ================================================ + # MCP tools + # ================================================ + elif mcp_tool_names and tool_call.function.name in mcp_tool_names: + action = MCPAction( + name=tool_call.function.name, + arguments=arguments, + ) + else: + raise FunctionCallNotExistsError( + f'Tool {tool_call.function.name} is not registered. ' + f'(arguments: {arguments}). ' + f'Please check the tool name and retry with an existing tool.' + ) + + except FunctionCallValidationError as e: + action = ValidationFailureAction( + function_name=tool_call.function.name, + error_message=str(e), + thought=thought if i == 0 else '', + ) + + except FunctionCallNotExistsError as e: + action = MessageAction( + content=str(e), + wait_for_response=False, + ) + + # Add thought to first action + if i == 0 and not isinstance( + action, (ValidationFailureAction, MessageAction) + ): + action = combine_thought(action, thought) + + # Add metadata for tool calling + action.tool_call_metadata = ToolCallMetadata( + tool_call_id=tool_call.id, + function_name=tool_call.function.name, + model_response=response, + total_calls_in_response=len(assistant_msg.tool_calls), + ) + actions.append(action) + else: + message_action = MessageAction( + content=str(assistant_msg.content) if assistant_msg.content else '', + wait_for_response=True, + ) + message_action.tool_call_metadata = ToolCallMetadata( + model_response=response, + total_calls_in_response=0, + ) + actions.append(message_action) + + for action in actions: + action.response_id = response.id + + assert len(actions) >= 1 + return actions diff --git a/openhands/agenthub/swe_agent/prompts/additional_info.j2 b/openhands/agenthub/swe_agent/prompts/additional_info.j2 new file mode 100644 index 000000000000..5e0ec14ba00f --- /dev/null +++ b/openhands/agenthub/swe_agent/prompts/additional_info.j2 @@ -0,0 +1,53 @@ +{% if repository_info %} + +At the user's request, repository {{ repository_info.repo_name }} has been cloned to {{ repository_info.repo_directory }} in the current working directory. +{% if repository_info.branch_name %}The repository has been checked out to branch "{{ repository_info.branch_name }}". + +IMPORTANT: You should work within the current branch "{{ repository_info.branch_name }}" unless: + 1. the user explicitly instructs otherwise + 2. the current branch is "main", "master", or another default branch where direct pushes may be unsafe +{% endif %} + +{% endif %} +{% if repository_instructions -%} + +{{ repository_instructions }} + +{% endif %} +{% if runtime_info -%} + +{% if runtime_info.working_dir %} +The current working directory is {{ runtime_info.working_dir }} +{% endif %} +{% if runtime_info.available_hosts %} +The user has access to the following hosts for accessing a web application, +each of which has a corresponding port: +{% for host, port in runtime_info.available_hosts.items() -%} +* {{ host }} (port {{ port }}) +{% endfor %} +When starting a web server, use the corresponding ports. You should also +set any options to allow iframes and CORS requests, and allow the server to +be accessed from any host (e.g. 0.0.0.0). +For example, if you are using vite.config.js, you should set server.host and server.allowedHosts to true +{% endif %} +{% if runtime_info.additional_agent_instructions %} +{{ runtime_info.additional_agent_instructions }} +{% endif %} +{% if runtime_info.custom_secrets_descriptions %} + +You have access to the following environment variables +{% for secret_name, secret_description in runtime_info.custom_secrets_descriptions.items() %} +* **${{ secret_name }}**: {{ secret_description }} +{% endfor %} + +{% endif %} +{% if runtime_info.date %} +Today's date is {{ runtime_info.date }} (UTC). +{% endif %} + +{% if conversation_instructions and conversation_instructions.content -%} + +{{ conversation_instructions.content }} + +{% endif %} +{% endif %} diff --git a/openhands/agenthub/swe_agent/prompts/microagent_info.j2 b/openhands/agenthub/swe_agent/prompts/microagent_info.j2 new file mode 100644 index 000000000000..264828fbe206 --- /dev/null +++ b/openhands/agenthub/swe_agent/prompts/microagent_info.j2 @@ -0,0 +1,8 @@ +{% for agent_info in triggered_agents %} + +The following information has been included based on a keyword match for "{{ agent_info.trigger }}". +It may or may not be relevant to the user's request. + +{{ agent_info.content }} + +{% endfor %} diff --git a/openhands/agenthub/swe_agent/prompts/system_prompt.j2 b/openhands/agenthub/swe_agent/prompts/system_prompt.j2 new file mode 100644 index 000000000000..5fe5dce8f609 --- /dev/null +++ b/openhands/agenthub/swe_agent/prompts/system_prompt.j2 @@ -0,0 +1,4 @@ +You are a helpful assistant that can interact with a computer to solve tasks. + +{% include 'additional_info.j2' %} +{% include 'microagent_info.j2' %} diff --git a/openhands/agenthub/swe_agent/prompts/system_prompt_long_horizon.j2 b/openhands/agenthub/swe_agent/prompts/system_prompt_long_horizon.j2 new file mode 100644 index 000000000000..6b4b3dd61c60 --- /dev/null +++ b/openhands/agenthub/swe_agent/prompts/system_prompt_long_horizon.j2 @@ -0,0 +1 @@ +{% include "system_prompt.j2" %} diff --git a/openhands/agenthub/swe_agent/prompts/user_prompt.j2 b/openhands/agenthub/swe_agent/prompts/user_prompt.j2 new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/openhands/agenthub/swe_agent/prompts/user_prompt.j2 @@ -0,0 +1 @@ + diff --git a/openhands/agenthub/swe_agent/swe_agent.py b/openhands/agenthub/swe_agent/swe_agent.py new file mode 100644 index 000000000000..9921d10e28b0 --- /dev/null +++ b/openhands/agenthub/swe_agent/swe_agent.py @@ -0,0 +1,183 @@ +"""SWE-Agent for OpenHands. + +A function-calling agent that uses SWE-Agent's native tools: +- bash: Execute bash commands +- str_replace_editor: View/create/edit files +- submit: Submit the solution +""" + +import os +from collections import deque +from typing import TYPE_CHECKING + +from openhands.llm.llm_registry import LLMRegistry + +if TYPE_CHECKING: + from litellm import ChatCompletionToolParam + + from openhands.events.action import Action + from openhands.llm.llm import ModelResponse + +import openhands.agenthub.swe_agent.function_calling as swe_agent_function_calling +from openhands.agenthub.swe_agent.tools.bash import BashTool +from openhands.agenthub.swe_agent.tools.str_replace_editor import StrReplaceEditorTool +from openhands.agenthub.swe_agent.tools.submit import SubmitTool +from openhands.controller.agent import Agent +from openhands.controller.state.state import State +from openhands.core.config import AgentConfig +from openhands.core.logger import openhands_logger as logger +from openhands.core.message import Message +from openhands.events.action import AgentFinishAction, MessageAction +from openhands.events.event import Event +from openhands.llm.llm_utils import check_tools +from openhands.memory.condenser import Condenser +from openhands.memory.condenser.condenser import Condensation, View +from openhands.memory.conversation_memory import ConversationMemory +from openhands.runtime.plugins import ( + AgentSkillsRequirement, + PluginRequirement, +) +from openhands.utils.prompt import PromptManager + + +class SWEAgent(Agent): + VERSION = '1.0' + """ + SWE-Agent style agent using function calling with SWE-Agent's native tools. + + This agent provides a simplified tool interface matching the SWE-Agent framework: + - bash: Execute bash commands + - str_replace_editor: View, create, and edit files + - submit: Submit the solution + + The agent uses function calling to invoke tools and maintains conversation + history for context-aware assistance. + """ + + sandbox_plugins: list[PluginRequirement] = [ + AgentSkillsRequirement(), + ] + + def __init__(self, config: AgentConfig, llm_registry: LLMRegistry) -> None: + super().__init__(config, llm_registry) + self.pending_actions: deque['Action'] = deque() + self.reset() + self.tools = self._get_tools() + + self.conversation_memory = ConversationMemory(self.config, self.prompt_manager) + + self.condenser = Condenser.from_config(self.config.condenser, llm_registry) + logger.debug(f'Using condenser: {type(self.condenser)}') + + self.llm = self.llm_registry.get_router(self.config) + + @property + def prompt_manager(self) -> PromptManager: + if self._prompt_manager is None: + prompt_dir = ( + self.config.custom_prompt_dir + if self.config.custom_prompt_dir + else os.path.join(os.path.dirname(__file__), 'prompts') + ) + + template_overrides = {} + if self.config.system_prompt_path: + template_overrides['system_prompt.j2'] = self.config.system_prompt_path + if self.config.system_prompt_long_horizon_path: + template_overrides['system_prompt_long_horizon.j2'] = ( + self.config.system_prompt_long_horizon_path + ) + + self._prompt_manager = PromptManager( + prompt_dir=prompt_dir, + system_prompt_filename=self.config.resolved_system_prompt_filename, + template_overrides=template_overrides if template_overrides else None, + ) + + return self._prompt_manager + + def _get_tools(self) -> list['ChatCompletionToolParam']: + return [BashTool, StrReplaceEditorTool, SubmitTool] + + def reset(self) -> None: + super().reset() + self.pending_actions.clear() + + def step(self, state: State) -> 'Action': + if self.pending_actions: + return self.pending_actions.popleft() + + latest_user_message = state.get_last_user_message() + if latest_user_message and latest_user_message.content.strip() == '/exit': + return AgentFinishAction() + + condensed_history: list[Event] = [] + match self.condenser.condensed_history(state): + case View(events=events): + condensed_history = events + + case Condensation(action=condensation_action): + return condensation_action + + logger.debug( + f'Processing {len(condensed_history)} events from a total of {len(state.history)} events' + ) + + initial_user_message = self._get_initial_user_message(state.history) + messages = self._get_messages(condensed_history, initial_user_message) + params: dict = { + 'messages': messages, + } + params['tools'] = check_tools(self.tools, self.llm.config) + params['extra_body'] = { + 'metadata': state.to_llm_metadata( + model_name=self.llm.config.model, agent_name=self.name + ) + } + response = self.llm.completion(**params) + logger.debug(f'Response from LLM: {response}') + actions = self.response_to_actions(response) + logger.debug(f'Actions after response_to_actions: {actions}') + for action in actions: + self.pending_actions.append(action) + return self.pending_actions.popleft() + + def _get_initial_user_message(self, history: list[Event]) -> MessageAction: + initial_user_message: MessageAction | None = None + for event in history: + if isinstance(event, MessageAction) and event.source == 'user': + initial_user_message = event + break + + if initial_user_message is None: + logger.error( + f'CRITICAL: Could not find the initial user MessageAction in the full {len(history)} events history.' + ) + raise ValueError( + 'Initial user message not found in history. Please report this issue.' + ) + return initial_user_message + + def _get_messages( + self, events: list[Event], initial_user_message: MessageAction + ) -> list[Message]: + if not self.prompt_manager: + raise Exception('Prompt Manager not instantiated.') + + messages = self.conversation_memory.process_events( + condensed_history=events, + initial_user_action=initial_user_message, + max_message_chars=self.llm.config.max_message_chars, + vision_is_active=self.llm.vision_is_active(), + ) + + if self.llm.is_caching_prompt_active(): + self.conversation_memory.apply_prompt_caching(messages) + + return messages + + def response_to_actions(self, response: 'ModelResponse') -> list['Action']: + return swe_agent_function_calling.response_to_actions( + response, + mcp_tool_names=list(self.mcp_tools.keys()), + ) diff --git a/openhands/agenthub/swe_agent/tools/__init__.py b/openhands/agenthub/swe_agent/tools/__init__.py new file mode 100644 index 000000000000..65512465e365 --- /dev/null +++ b/openhands/agenthub/swe_agent/tools/__init__.py @@ -0,0 +1,5 @@ +from openhands.agenthub.swe_agent.tools.bash import BashTool +from openhands.agenthub.swe_agent.tools.str_replace_editor import StrReplaceEditorTool +from openhands.agenthub.swe_agent.tools.submit import SubmitTool + +__all__ = ['BashTool', 'StrReplaceEditorTool', 'SubmitTool'] diff --git a/openhands/agenthub/swe_agent/tools/bash.py b/openhands/agenthub/swe_agent/tools/bash.py new file mode 100644 index 000000000000..76dafa36450b --- /dev/null +++ b/openhands/agenthub/swe_agent/tools/bash.py @@ -0,0 +1,21 @@ +from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk + +SWE_AGENT_BASH_TOOL_NAME = 'bash' + +BashTool: ChatCompletionToolParam = ChatCompletionToolParam( + type='function', + function=ChatCompletionToolParamFunctionChunk( + name=SWE_AGENT_BASH_TOOL_NAME, + description='runs the given command directly in bash', + parameters={ + 'type': 'object', + 'properties': { + 'command': { + 'type': 'string', + 'description': 'The bash command to execute', + }, + }, + 'required': ['command'], + }, + ), +) diff --git a/openhands/agenthub/swe_agent/tools/str_replace_editor.py b/openhands/agenthub/swe_agent/tools/str_replace_editor.py new file mode 100644 index 000000000000..7e229610ebdf --- /dev/null +++ b/openhands/agenthub/swe_agent/tools/str_replace_editor.py @@ -0,0 +1,69 @@ +from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk + +SWE_AGENT_STR_REPLACE_EDITOR_TOOL_NAME = 'str_replace_editor' + +StrReplaceEditorTool: ChatCompletionToolParam = ChatCompletionToolParam( + type='function', + function=ChatCompletionToolParamFunctionChunk( + name=SWE_AGENT_STR_REPLACE_EDITOR_TOOL_NAME, + description=( + 'Custom editing tool for viewing, creating and editing files\n' + '* State is persistent across command calls and discussions with the user\n' + '* If `path` is a file, `view` displays the result of applying `cat -n`. ' + 'If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n' + '* The `create` command cannot be used if the specified `path` already exists as a file\n' + '* If a `command` generates a long output, it will be truncated and marked with ``\n' + '* The `undo_edit` command will revert the last edit made to the file at `path`\n' + 'Notes for using the `str_replace` command:\n' + '* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. ' + 'Be mindful of whitespaces!\n' + '* If the `old_str` parameter is not unique in the file, the replacement will not be performed. ' + 'Make sure to include enough context in `old_str` to make it unique\n' + '* The `new_str` parameter should contain the edited lines that should replace the `old_str`' + ), + parameters={ + 'type': 'object', + 'properties': { + 'command': { + 'type': 'string', + 'description': 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.', + 'enum': ['view', 'create', 'str_replace', 'insert', 'undo_edit'], + }, + 'path': { + 'type': 'string', + 'description': 'Absolute path to file or directory, e.g. `/testbed/file.py` or `/testbed`.', + }, + 'file_text': { + 'type': 'string', + 'description': 'Required parameter of `create` command, with the content of the file to be created.', + }, + 'old_str': { + 'type': 'string', + 'description': 'Required parameter of `str_replace` command containing the string in `path` to replace.', + }, + 'new_str': { + 'type': 'string', + 'description': ( + 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). ' + 'Required parameter of `insert` command containing the string to insert.' + ), + }, + 'insert_line': { + 'type': 'integer', + 'description': 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.', + }, + 'view_range': { + 'type': 'array', + 'items': {'type': 'integer'}, + 'description': ( + 'Optional parameter of `view` command when `path` points to a file. ' + 'If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, ' + 'e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. ' + 'Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.' + ), + }, + }, + 'required': ['command', 'path'], + }, + ), +) diff --git a/openhands/agenthub/swe_agent/tools/submit.py b/openhands/agenthub/swe_agent/tools/submit.py new file mode 100644 index 000000000000..c9db1f8bcc09 --- /dev/null +++ b/openhands/agenthub/swe_agent/tools/submit.py @@ -0,0 +1,16 @@ +from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk + +SWE_AGENT_SUBMIT_TOOL_NAME = 'submit' + +SubmitTool: ChatCompletionToolParam = ChatCompletionToolParam( + type='function', + function=ChatCompletionToolParamFunctionChunk( + name=SWE_AGENT_SUBMIT_TOOL_NAME, + description='submits the current file', + parameters={ + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), +)