Skip to content

Commit

Permalink
refactor(runtime): Use openhands-aci file editor directly in runtime …
Browse files Browse the repository at this point in the history
…instead of execute it through ipython (#6671)

Co-authored-by: openhands <[email protected]>
Co-authored-by: Graham Neubig <[email protected]>
Co-authored-by: Engel Nyst <[email protected]>
  • Loading branch information
4 people authored Feb 11, 2025
1 parent 6772227 commit 3188646
Show file tree
Hide file tree
Showing 15 changed files with 1,288 additions and 239 deletions.
17 changes: 15 additions & 2 deletions frontend/src/types/core/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export interface FileReadAction extends OpenHandsActionEvent<"read"> {
args: {
path: string;
thought: string;
translated_ipython_code: string | null;
security_risk: ActionSecurityRisk | null;
impl_source?: string;
view_range?: number[] | null;
};
}

Expand All @@ -100,7 +102,18 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> {
source: "agent";
args: {
path: string;
translated_ipython_code: string;
command?: string;
file_text?: string | null;
view_range?: number[] | null;
old_str?: string | null;
new_str?: string | null;
insert_line?: number | null;
content?: string;
start?: number;
end?: number;
thought: string;
security_risk: ActionSecurityRisk | null;
impl_source?: string;
};
}

Expand Down
28 changes: 14 additions & 14 deletions openhands/agenthub/codeact_agent/function_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
FunctionCallNotExistsError,
FunctionCallValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
AgentDelegateAction,
Expand Down Expand Up @@ -541,26 +540,27 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
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']
}

# We implement this in agent_skills, which can be used via Jupyter
# convert tool_call.function.arguments to kwargs that can be passed to file_editor
code = f'print(file_editor(**{arguments}))'
logger.debug(
f'TOOL CALL: str_replace_editor -> file_editor with code: {code}'
)

if arguments['command'] == 'view':
if command == 'view':
action = FileReadAction(
path=arguments['path'],
translated_ipython_code=code,
path=path,
impl_source=FileReadSource.OH_ACI,
view_range=other_kwargs.get('view_range', None),
)
else:
if 'view_range' in other_kwargs:
# Remove view_range from other_kwargs since it is not needed for FileEditAction
other_kwargs.pop('view_range')
action = FileEditAction(
path=arguments['path'],
content='', # dummy value -- we don't need it
translated_ipython_code=code,
path=path,
command=command,
impl_source=FileEditSource.OH_ACI,
**other_kwargs,
)
elif tool_call.function.name == 'browser':
if 'code' not in arguments:
Expand Down
76 changes: 63 additions & 13 deletions openhands/events/action/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class FileReadAction(Action):
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk | None = None
impl_source: FileReadSource = FileReadSource.DEFAULT
translated_ipython_code: str = '' # translated openhands-aci IPython code
view_range: list[int] | None = None # ONLY used in OH_ACI mode

@property
def message(self) -> str:
Expand Down Expand Up @@ -60,29 +60,79 @@ def __repr__(self) -> str:

@dataclass
class FileEditAction(Action):
"""Edits a file by provided a draft at a given path.
Can be set to edit specific lines using start and end (1-index, inclusive) if the file is too long.
Default lines 1:-1 (whole file).
If start is set to -1, the FileEditAction will simply append the content to the file.
"""Edits a file using various commands including view, create, str_replace, insert, and undo_edit.
This class supports two main modes of operation:
1. LLM-based editing (impl_source = FileEditSource.LLM_BASED_EDIT)
2. ACI-based editing (impl_source = FileEditSource.OH_ACI)
Attributes:
path (str): The path to the file being edited. Works for both LLM-based and OH_ACI editing.
OH_ACI only arguments:
command (str): The editing command to be performed (view, create, str_replace, insert, undo_edit, write).
file_text (str): The content of the file to be created (used with 'create' command in OH_ACI mode).
old_str (str): The string to be replaced (used with 'str_replace' command in OH_ACI mode).
new_str (str): The string to replace old_str (used with 'str_replace' and 'insert' commands in OH_ACI mode).
insert_line (int): The line number after which to insert new_str (used with 'insert' command in OH_ACI mode).
LLM-based editing arguments:
content (str): The content to be written or edited in the file (used in LLM-based editing and 'write' command).
start (int): The starting line for editing (1-indexed, inclusive). Default is 1.
end (int): The ending line for editing (1-indexed, inclusive). Default is -1 (end of file).
thought (str): The reasoning behind the edit action.
action (str): The type of action being performed (always ActionType.EDIT).
runnable (bool): Indicates if the action can be executed (always True).
security_risk (ActionSecurityRisk | None): Indicates any security risks associated with the action.
impl_source (FileEditSource): The source of the implementation (LLM_BASED_EDIT or OH_ACI).
Usage:
- For LLM-based editing: Use path, content, start, and end attributes.
- For ACI-based editing: Use path, command, and the appropriate attributes for the specific command.
Note:
- If start is set to -1 in LLM-based editing, the content will be appended to the file.
- The 'write' command behaves similarly to LLM-based editing, using content, start, and end attributes.
"""

path: str
content: str

# OH_ACI arguments
command: str = ''
file_text: str | None = None
old_str: str | None = None
new_str: str | None = None
insert_line: int | None = None

# LLM-based editing arguments
content: str = ''
start: int = 1
end: int = -1

# Shared arguments
thought: str = ''
action: str = ActionType.EDIT
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk | None = None
impl_source: FileEditSource = FileEditSource.LLM_BASED_EDIT
translated_ipython_code: str = ''
impl_source: FileEditSource = FileEditSource.OH_ACI

def __repr__(self) -> str:
ret = '**FileEditAction**\n'
ret += f'Thought: {self.thought}\n'
ret += f'Range: [L{self.start}:L{self.end}]\n'
ret += f'Path: [{self.path}]\n'
ret += f'Content:\n```\n{self.content}\n```\n'
ret += f'Thought: {self.thought}\n'

if self.impl_source == FileEditSource.LLM_BASED_EDIT:
ret += f'Range: [L{self.start}:L{self.end}]\n'
ret += f'Content:\n```\n{self.content}\n```\n'
else: # OH_ACI mode
ret += f'Command: {self.command}\n'
if self.command == 'create':
ret += f'Created File with Text:\n```\n{self.file_text}\n```\n'
elif self.command == 'str_replace':
ret += f'Old String: ```\n{self.old_str}\n```\n'
ret += f'New String: ```\n{self.new_str}\n```\n'
elif self.command == 'insert':
ret += f'Insert Line: {self.insert_line}\n'
ret += f'New String: ```\n{self.new_str}\n```\n'
elif self.command == 'undo_edit':
ret += 'Undo Edit\n'
# We ignore "view" command because it will be mapped to a FileReadAction
return ret
17 changes: 11 additions & 6 deletions openhands/events/observation/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,18 @@ class FileEditObservation(Observation):
The observation includes both the old and new content of the file, and can
generate a diff visualization showing the changes. The diff is computed lazily
and cached to improve performance.
The .content property can either be:
- Git diff in LLM-based editing mode
- the rendered message sent to the LLM in OH_ACI mode (e.g., "The file /path/to/file.txt is created with the provided content.")
"""

path: str
prev_exist: bool
old_content: str
new_content: str
path: str = ''
prev_exist: bool = False
old_content: str | None = None
new_content: str | None = None
observation: str = ObservationType.EDIT
impl_source: FileEditSource = FileEditSource.LLM_BASED_EDIT
formatted_output_and_error: str = ''
_diff_cache: str | None = None # Cache for the diff visualization

@property
Expand All @@ -75,6 +78,8 @@ def get_edit_groups(self, n_context_lines: int = 2) -> list[dict[str, list[str]]
Returns:
A list of edit groups, where each group contains before/after edits.
"""
if self.old_content is None or self.new_content is None:
return []
old_lines = self.old_content.split('\n')
new_lines = self.new_content.split('\n')
# Borrowed from difflib.unified_diff to directly parse into structured format
Expand Down Expand Up @@ -173,7 +178,7 @@ def visualize_diff(
def __str__(self) -> str:
"""Get a string representation of the file edit observation."""
if self.impl_source == FileEditSource.OH_ACI:
return self.formatted_output_and_error
return self.content

if not self.prev_exist:
assert (
Expand Down
39 changes: 36 additions & 3 deletions openhands/events/serialization/action.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from openhands.core.exceptions import LLMMalformedActionError
from openhands.events.action.action import Action
from openhands.events.action.agent import (
Expand Down Expand Up @@ -38,6 +40,38 @@
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]


def handle_action_deprecated_args(args: dict) -> dict:
# keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
if 'keep_prompt' in args:
args.pop('keep_prompt')

# Handle translated_ipython_code deprecation
if 'translated_ipython_code' in args:
code = args.pop('translated_ipython_code')

# Check if it's a file_editor call
file_editor_pattern = r'print\(file_editor\(\*\*(.*?)\)\)'
if code is not None and (match := re.match(file_editor_pattern, code)):
try:
# Extract and evaluate the dictionary string
import ast

file_args = ast.literal_eval(match.group(1))

# Update args with the extracted file editor arguments
args.update(file_args)
except (ValueError, SyntaxError):
# If parsing fails, just remove the translated_ipython_code
pass

if args.get('command') == 'view':
args.pop(
'command'
) # "view" will be translated to FileReadAction which doesn't have a command argument

return args


def action_from_dict(action: dict) -> Action:
if not isinstance(action, dict):
raise LLMMalformedActionError('action must be a dictionary')
Expand Down Expand Up @@ -67,9 +101,8 @@ def action_from_dict(action: dict) -> Action:
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')

# keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
if 'keep_prompt' in args:
args.pop('keep_prompt')
# handle deprecated args
args = handle_action_deprecated_args(args)

try:
decoded_action = action_class(**args)
Expand Down
28 changes: 19 additions & 9 deletions openhands/events/serialization/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ def _update_cmd_output_metadata(
return metadata


def handle_observation_deprecated_extras(extras: dict) -> dict:
# These are deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
if 'exit_code' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), exit_code=extras.pop('exit_code')
)
if 'command_id' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), pid=extras.pop('command_id')
)

# formatted_output_and_error has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/6671
if 'formatted_output_and_error' in extras:
extras.pop('formatted_output_and_error')
return extras


def observation_from_dict(observation: dict) -> Observation:
observation = observation.copy()
if 'observation' not in observation:
Expand All @@ -78,15 +95,8 @@ def observation_from_dict(observation: dict) -> Observation:
content = observation.pop('content', '')
extras = copy.deepcopy(observation.pop('extras', {}))

# Handle legacy attributes for CmdOutputObservation
if 'exit_code' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), exit_code=extras.pop('exit_code')
)
if 'command_id' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), pid=extras.pop('command_id')
)
extras = handle_observation_deprecated_extras(extras)

# convert metadata to CmdOutputMetadata if it is a dict
if observation_class is CmdOutputObservation:
if 'metadata' in extras and isinstance(extras['metadata'], dict):
Expand Down
Loading

0 comments on commit 3188646

Please sign in to comment.