Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Added FileEditAction to enable edits using diff format. #3777

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
32 changes: 31 additions & 1 deletion agenthub/codeact_agent/action_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
AgentDelegateAction,
AgentFinishAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
MessageAction,
)
Expand All @@ -16,6 +17,7 @@ class CodeActResponseParser(ResponseParser):
- CmdRunAction(command) - bash command to run
- IPythonRunCellAction(code) - IPython code to run
- AgentDelegateAction(agent, inputs) - delegate action for (sub)task
- FileEditAction(diff_block) - Search/Replace block to edit.
- MessageAction(content) - Message action to run (e.g. ask for clarification)
- AgentFinishAction() - end the interaction
"""
Expand All @@ -28,6 +30,7 @@ def __init__(self):
CodeActActionParserCmdRun(),
CodeActActionParserIPythonRunCell(),
CodeActActionParserAgentDelegate(),
CodeActActionParserFileEdit(),
]
self.default_parser = CodeActActionParserMessage()

Expand All @@ -39,7 +42,7 @@ def parse_response(self, response) -> str:
action = response.choices[0].message.content
if action is None:
return ''
for lang in ['bash', 'ipython', 'browse']:
for lang in ['bash', 'ipython', 'edit', 'browse']:
if f'<execute_{lang}>' in action and f'</execute_{lang}>' not in action:
action += f'</execute_{lang}>'
return action
Expand Down Expand Up @@ -158,6 +161,33 @@ def parse(self, action_str: str) -> Action:
return AgentDelegateAction(agent='BrowsingAgent', inputs={'task': task})


class CodeActActionParserFileEdit(ActionParser):
"""Parser action:
- FileEditAction(diff_block) - Search/Replace block to edit.
"""

def __init__(
self,
):
self.diff_block = None

def check_condition(self, action_str: str) -> bool:
RajWorking marked this conversation as resolved.
Show resolved Hide resolved
self.diff_block = re.search(
r'<execute_edit>(.*)</execute_edit>', action_str, re.DOTALL
)
return self.diff_block is not None

def parse(self, action_str: str) -> Action:
assert (
self.diff_block is not None
), 'self.diff_block should not be None when parse is called'
thought = action_str.replace(self.diff_block.group(0), '').strip()
return FileEditAction(
diff_block=self.diff_block.group(1).strip(),
thought=thought,
)


class CodeActActionParserMessage(ActionParser):
"""Parser action:
- MessageAction(content) - Message action to run (e.g. ask for clarification)
Expand Down
12 changes: 11 additions & 1 deletion agenthub/codeact_agent/codeact_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
AgentDelegateAction,
AgentFinishAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
MessageAction,
)
from openhands.events.observation import (
AgentDelegateObservation,
CmdOutputObservation,
FileEditObservation,
IPythonRunCellObservation,
UserRejectObservation,
)
Expand Down Expand Up @@ -103,6 +105,8 @@ def action_to_str(self, action: Action) -> str:
return f'{action.thought}\n<execute_ipython>\n{action.code}\n</execute_ipython>'
elif isinstance(action, AgentDelegateAction):
return f'{action.thought}\n<execute_browse>\n{action.inputs["task"]}\n</execute_browse>'
elif isinstance(action, FileEditAction):
return f'{action.thought}\n<execute_edit>\n{action.diff_block}\n</execute_edit>'
elif isinstance(action, MessageAction):
return action.content
elif isinstance(action, AgentFinishAction) and action.source == 'agent':
Expand All @@ -112,6 +116,7 @@ def action_to_str(self, action: Action) -> str:
def get_action_message(self, action: Action) -> Message | None:
if (
isinstance(action, AgentDelegateAction)
or isinstance(action, FileEditAction)
or isinstance(action, CmdRunAction)
or isinstance(action, IPythonRunCellAction)
or isinstance(action, MessageAction)
Expand Down Expand Up @@ -152,6 +157,9 @@ def get_observation_message(self, obs: Observation) -> Message | None:
text = '\n'.join(splitted)
text = truncate_content(text, max_message_chars)
return Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, FileEditObservation):
text = obs_prefix + truncate_content(obs.content, max_message_chars)
return Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, AgentDelegateObservation):
text = obs_prefix + truncate_content(str(obs.outputs), max_message_chars)
return Message(role='user', content=[TextContent(text=text)])
Expand All @@ -160,7 +168,7 @@ def get_observation_message(self, obs: Observation) -> Message | None:
text += '\n[Error occurred in processing last action]'
return Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, UserRejectObservation):
text = 'OBSERVATION:\n' + truncate_content(obs.content, max_message_chars)
text = obs_prefix + truncate_content(obs.content, max_message_chars)
text += '\n[Last action has been rejected by the user]'
return Message(role='user', content=[TextContent(text=text)])
else:
Expand All @@ -183,6 +191,7 @@ def step(self, state: State) -> Action:
- CmdRunAction(command) - bash command to run
- IPythonRunCellAction(code) - IPython code to run
- AgentDelegateAction(agent, inputs) - delegate action for (sub)task
- FileEditAction(diff_block) - Search/Replace block to edit.
- MessageAction(content) - Message action to run (e.g. ask for clarification)
- AgentFinishAction() - end the interaction
"""
Expand All @@ -199,6 +208,7 @@ def step(self, state: State) -> Action:
'</execute_ipython>',
'</execute_bash>',
'</execute_browse>',
'</execute_edit>',
],
'temperature': 0.0,
}
Expand Down
32 changes: 27 additions & 5 deletions agenthub/codeact_agent/system_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,44 @@ the assistant should retry running the command in the background.
The assistant can browse the Internet with <execute_browse> and </execute_browse>.
For example, <execute_browse> Tell me the usa's president using google search </execute_browse>.
Or <execute_browse> Tell me what is in http://example.com </execute_browse>.
{% endset %}
{% set EDIT_DIFF_PREFIX %}
The assistant can edit files with <execute_edit> and </execute_edit>. Each change must be described with a SEARCH/REPLACE block.
Every SEARCH section must EXACTLY MATCH the existing file content, character for character, including all comments, docstrings, etc. SEARCH/REPLACE blocks will replace all matching occurrences. Include enough lines to make the SEARCH blocks uniquely match the lines to change.
Keep SEARCH/REPLACE blocks concise. Break large SEARCH/REPLACE blocks into a series of smaller blocks that each change a small portion of the file.
To move code within a file, use 2 SEARCH/REPLACE blocks: 1 to delete it from its current location, 1 to insert it in the new location.
If you want to put code in a new file, use a SEARCH/REPLACE block with: a new file path, an empty `SEARCH` section and the new file's contents in the `REPLACE` section.

Every SEARCH/REPLACE block must use this format:
1. The FULL file path alone on a line, verbatim. No bold asterisks, no quotes around it, no escaping of characters, etc.
2. The start of search block: <<<<<<< SEARCH
3. A contiguous chunk of lines to search for in the existing source code
4. The dividing line: =======
5. The lines to replace into the source code
6. The end of the replace block: >>>>>>> REPLACE

For example,
<execute_edit>
demo.py
<<<<<<< SEARCH
print("hello")
=======
print("goodbye")
>>>>>>> REPLACE
</execute_edit>

{% endset %}
{% set PIP_INSTALL_PREFIX %}
The assistant can install Python packages using the %pip magic command in an IPython environment by using the following syntax: <execute_ipython> %pip install [package needed] </execute_ipython> and should always import packages and define variables before starting to use them.
{% endset %}
{% set SYSTEM_PREFIX = MINIMAL_SYSTEM_PREFIX + BROWSING_PREFIX + PIP_INSTALL_PREFIX %}
{% set SYSTEM_PREFIX = MINIMAL_SYSTEM_PREFIX + BROWSING_PREFIX + EDIT_DIFF_PREFIX + PIP_INSTALL_PREFIX %}
{% set COMMAND_DOCS %}
Apart from the standard Python library, the assistant can also use the following functions (already imported) in <execute_ipython> environment:
{{ agent_skills_docs }}
IMPORTANT:
- `open_file` only returns the first 100 lines of the file by default! The assistant MUST use `scroll_down` repeatedly to read the full file BEFORE making edits!
- The assistant shall adhere to THE `edit_file_by_replace`, `append_file` and `insert_content_at_line` FUNCTIONS REQUIRING PROPER INDENTATION. If the assistant would like to add the line ' print(x)', it must fully write the line out, with all leading spaces before the code!
- Indentation is important and code that is not indented correctly will fail and require fixing before it can be run.
- Any code issued should be less than 50 lines to avoid context being cut off!
- After EVERY `create_file` the method `append_file` shall be used to write the FIRST content!
- For `edit_file_by_replace` NEVER provide empty parameters!
- For `edit_file_by_replace` the file must be read fully before any replacements!
{% endset %}
{% set SYSTEM_SUFFIX %}
Responses should be concise.
Expand Down
68 changes: 25 additions & 43 deletions agenthub/codeact_agent/user_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ OBSERVATION:

ASSISTANT:
Now I will write the Python code for starting a web server and save it to the file `app.py`:
<execute_ipython>
EDITED_CODE="""from flask import Flask
<execute_edit>
app.py
<<<<<<< SEARCH
=======
from flask import Flask
app = Flask(__name__)

@app.route('/')
Expand All @@ -29,30 +32,17 @@ def index():
return str(numbers)

if __name__ == '__main__':
app.run(port=5000)"""
app.run(port=5000)
>>>>>>> REPLACE
</execute_edit>

insert_content_at_line(
'app.py',
1,
EDITED_CODE,
)
</execute_ipython>

USER:
OBSERVATION:
(this is the beginning of the file)
1|from flask import Flask
2|app = Flask(__name__)
3|
4|@app.route('/')
5|def index():
6| numbers = list(range(1, 11))
7| return str(numbers)
8|
9|if __name__ == '__main__':
10| app.run(port=5000)
(this is the end of the file)
[File updated (edited at line 1). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]
I updated the file app.py by
appending:
from flask import Flask
app = Flask(__name__)

ASSISTANT:
I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you:
Expand Down Expand Up @@ -177,30 +167,22 @@ USER:

ASSISTANT:
I should edit the file to display the numbers in a table format. I should include correct indentation. Let me update the file:
<execute_ipython>
edit_file_by_replace(
'app.py',
to_replace=" return str(numbers)",
new_content=" return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'",
)
</execute_ipython>
<execute_edit>
app.py
<<<<<<< SEARCH
return str(numbers)
=======
return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
>>>>>>> REPLACE
</execute_edit>

USER:
Observation:
[File: /workspace/app.py (10 lines total after edit)]
(this is the beginning of the file)
1|from flask import Flask
2|app = Flask(__name__)
3|
4|@app.route('/')
5|def index():
6| numbers = list(range(1, 11))
7| return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
8|
9|if __name__ == '__main__':
10| app.run(port=5000)
(this is the end of the file)
[File updated (edited at line 7). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]
I updated the file app.py by
replacing:
return str(numbers)
with:
return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'

ASSISTANT:
Running the updated file:
Expand Down
4 changes: 4 additions & 0 deletions openhands/core/schema/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class ActionTypeSchema(BaseModel):
"""Writes the content to a file.
"""

EDIT: str = Field(default='edit')
"""Edits the content of a file.
"""

RUN: str = Field(default='run')
"""Runs a command.
"""
Expand Down
4 changes: 4 additions & 0 deletions openhands/core/schema/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ class ObservationTypeSchema(BaseModel):

WRITE: str = Field(default='write')

EDIT: str = Field(default='edit')
"""The edited file
"""

BROWSE: str = Field(default='browse')
"""The HTML content of a URL
"""
Expand Down
7 changes: 6 additions & 1 deletion openhands/events/action/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction
from openhands.events.action.empty import NullAction
from openhands.events.action.files import FileReadAction, FileWriteAction
from openhands.events.action.files import (
FileEditAction,
FileReadAction,
FileWriteAction,
)
from openhands.events.action.message import MessageAction
from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction

Expand All @@ -21,6 +25,7 @@
'BrowseInteractiveAction',
'FileReadAction',
'FileWriteAction',
'FileEditAction',
'AgentFinishAction',
'AgentRejectAction',
'AgentDelegateAction',
Expand Down
20 changes: 20 additions & 0 deletions openhands/events/action/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,23 @@ class FileWriteAction(Action):
@property
def message(self) -> str:
return f'Writing file: {self.path}'


@dataclass
class FileEditAction(Action):
diff_block: str
thought: str = ''
action: str = ActionType.EDIT
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk | None = None

def __str__(self) -> str:
ret = '**EditFileAction**\n'
if self.thought:
ret += f'THOUGHT: {self.thought}\n'
ret += f'DIFF BLOCK:\n{self.diff_block}\n'
return ret

@property
def message(self) -> str:
return f'Edit Diff block: {self.diff_block}'
7 changes: 6 additions & 1 deletion openhands/events/observation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from openhands.events.observation.delegate import AgentDelegateObservation
from openhands.events.observation.empty import NullObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.files import FileReadObservation, FileWriteObservation
from openhands.events.observation.files import (
FileEditObservation,
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.observation import Observation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.observation.success import SuccessObservation
Expand All @@ -20,6 +24,7 @@
'BrowserOutputObservation',
'FileReadObservation',
'FileWriteObservation',
'FileEditObservation',
'ErrorObservation',
'AgentStateChangedObservation',
'AgentDelegateObservation',
Expand Down
27 changes: 27 additions & 0 deletions openhands/events/observation/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,30 @@ class FileWriteObservation(Observation):
@property
def message(self) -> str:
return f'I wrote to the file {self.path}.'


@dataclass
class FileEditObservation(Observation):
"""This data class represents a file edit operation"""

path: str
search_block: str
replace_block: str
observation: str = ObservationType.EDIT

@property
def message(self) -> str:
if self.search_block:
return (
f'I updated the file {self.path} by \n'
f'replacing:\n {self.search_block}\n'
f'with:\n {self.replace_block}\n'
)
else:
return (
f'I updated the file {self.path} by \n'
f'appending:\n {self.replace_block}\n'
)

def __str__(self) -> str:
return f'**FileEditObservation**\n' f'DIFF BLOCK: {self.content}\n'
Loading
Loading