Compare commits

..

14 Commits

Author SHA1 Message Date
dependabot[bot] 3e79cd12a6 chore(deps-dev): bump @types/react from 18.3.8 to 18.3.9 in /frontend (#4029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 14:41:21 +04:00
tobitege 2cc1c3ef42 (enh) Docker runtime builder with BuildKit support, enh. caching (#4009) 2024-09-26 08:50:53 +02:00
dependabot[bot] ef0b08a46e chore(deps-dev): bump tailwindcss from 3.4.12 to 3.4.13 in /frontend (#4030)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 05:28:03 +00:00
dependabot[bot] f1d5202884 chore(deps): bump vite from 5.4.7 to 5.4.8 in /frontend (#4046) 2024-09-26 12:56:35 +08:00
dependabot[bot] 11cedfb854 chore(deps): bump google-cloud-aiplatform from 1.67.1 to 1.68.0 (#4051) 2024-09-26 12:56:16 +08:00
dependabot[bot] 6d103a0db2 chore(deps-dev): bump @types/node from 22.6.1 to 22.7.0 in /frontend (#4047) 2024-09-26 12:56:00 +08:00
Engel Nyst 798aaeaef6 remove Exception in the agent (#4054) 2024-09-26 06:39:17 +02:00
tofarr 0df4b97e5b Fix startup statuses (#4053) 2024-09-25 14:38:32 -06:00
Xingyao Wang 81b3cd71b3 [eval] log evaluating warnings directly to console (#4026) 2024-09-26 03:42:32 +08:00
Robert Brennan 9241ae2148 Fix persistence of "advanced settings" (#4038) 2024-09-25 12:57:08 -04:00
dependabot[bot] d3f86e052a chore(deps-dev): bump llama-index from 0.11.12 to 0.11.13 (#4044)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 18:24:09 +02:00
dependabot[bot] e0c65f8f9c chore(deps): bump google-generativeai from 0.8.1 to 0.8.2 (#4050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 18:22:01 +02:00
dependabot[bot] 394ab360a8 chore(deps): bump boto3 from 1.35.25 to 1.35.26 (#4048)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 18:21:27 +02:00
dependabot[bot] 8a146d5ced chore(deps): bump litellm from 1.48.0 to 1.48.1 (#4049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 18:20:47 +02:00
36 changed files with 767 additions and 438 deletions
+25 -7
View File
@@ -14,20 +14,38 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: true
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: false
swap-storage: true
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Set up environment
run: |
curl -sSL https://install.python-poetry.org | python3 -
poetry install --without evaluation,llama-index
poetry run playwright install --with-deps chromium
wget https://huggingface.co/BAAI/bge-small-en-v1.5/raw/main/1_Pooling/config.json -P /tmp/llama_index/models--BAAI--bge-small-en-v1.5/snapshots/5c38ec7c405ec4b44b94cc5a9bb96e735b38267a/1_Pooling/
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation,llama-index
- name: Build Environment
run: make build
- name: Run tests
run: |
set -e
poetry run python openhands/core/main.py -t "do a flip" -d ./workspace/ -c DummyAgent
poetry run python3 openhands/core/main.py -t "do a flip" -d ./workspace/ -c DummyAgent
- name: Check exit code
run: |
if [ $? -ne 0 ]; then
+51
View File
@@ -115,6 +115,23 @@ jobs:
base_image: ['nikolaik']
steps:
- uses: actions/checkout@v4
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: true
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: false
swap-storage: true
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
# Forked repos can't push to GHCR, so we need to download the image as an artifact
- name: Download runtime image for fork
if: github.event.pull_request.head.repo.fork
@@ -176,6 +193,23 @@ jobs:
base_image: ['nikolaik']
steps:
- uses: actions/checkout@v4
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: true
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: false
swap-storage: true
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
# Forked repos can't push to GHCR, so we need to download the image as an artifact
- name: Download runtime image for fork
if: github.event.pull_request.head.repo.fork
@@ -238,6 +272,23 @@ jobs:
base_image: ['nikolaik']
steps:
- uses: actions/checkout@v4
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: true
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: false
swap-storage: true
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
# Forked repos can't push to GHCR, so we need to download the image as an artifact
- name: Download runtime image for fork
if: github.event.pull_request.head.repo.fork
+6
View File
@@ -89,6 +89,9 @@ jobs:
sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock
- name: Build Environment
run: make build
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Run Tests
run: poetry run pytest --forked --cov=agenthub --cov=openhands --cov-report=xml ./tests/unit
- name: Upload coverage to Codecov
@@ -107,6 +110,9 @@ jobs:
python-version: ['3.11']
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
@@ -29,6 +29,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Set up Python
uses: actions/setup-python@v5
with:
+3
View File
@@ -15,6 +15,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Set up Python
uses: actions/setup-python@v5
with:
+3
View File
@@ -21,6 +21,9 @@ jobs:
steps:
- name: install git, github cli
run: apt-get install -y git gh
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Checkout Repository
uses: actions/checkout@v4
- name: Write Task File
+1 -31
View File
@@ -6,7 +6,6 @@ from openhands.events.action import (
AgentDelegateAction,
AgentFinishAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
MessageAction,
)
@@ -17,7 +16,6 @@ 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
"""
@@ -30,7 +28,6 @@ class CodeActResponseParser(ResponseParser):
CodeActActionParserCmdRun(),
CodeActActionParserIPythonRunCell(),
CodeActActionParserAgentDelegate(),
CodeActActionParserFileEdit(),
]
self.default_parser = CodeActActionParserMessage()
@@ -42,7 +39,7 @@ class CodeActResponseParser(ResponseParser):
action = response.choices[0].message.content
if action is None:
return ''
for lang in ['bash', 'ipython', 'edit', 'browse']:
for lang in ['bash', 'ipython', 'browse']:
if f'<execute_{lang}>' in action and f'</execute_{lang}>' not in action:
action += f'</execute_{lang}>'
return action
@@ -161,33 +158,6 @@ class CodeActActionParserAgentDelegate(ActionParser):
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:
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)
+3 -25
View File
@@ -5,22 +5,18 @@ from agenthub.codeact_agent.action_parser import CodeActResponseParser
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.exceptions import OperationCancelled
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import ImageContent, Message, TextContent
from openhands.events.action import (
Action,
AgentDelegateAction,
AgentFinishAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
MessageAction,
)
from openhands.events.observation import (
AgentDelegateObservation,
CmdOutputObservation,
FileEditObservation,
IPythonRunCellObservation,
UserRejectObservation,
)
@@ -38,7 +34,7 @@ from openhands.utils.prompt import PromptManager
class CodeActAgent(Agent):
VERSION = '1.10'
VERSION = '1.9'
"""
The Code Act Agent is a minimalist agent.
The agent works by passing the model a list of action-observation pairs and prompting the model to take the next step.
@@ -106,8 +102,6 @@ class CodeActAgent(Agent):
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':
@@ -117,7 +111,6 @@ class CodeActAgent(Agent):
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)
@@ -158,9 +151,6 @@ class CodeActAgent(Agent):
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(
obs.outputs['content'] if 'content' in obs.outputs else '',
@@ -172,7 +162,7 @@ class CodeActAgent(Agent):
text += '\n[Error occurred in processing last action]'
return Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, UserRejectObservation):
text = obs_prefix + truncate_content(obs.content, max_message_chars)
text = 'OBSERVATION:\n' + 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:
@@ -195,7 +185,6 @@ class CodeActAgent(Agent):
- 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
"""
@@ -212,7 +201,6 @@ class CodeActAgent(Agent):
'</execute_ipython>',
'</execute_bash>',
'</execute_browse>',
'</execute_edit>',
],
}
@@ -221,17 +209,7 @@ class CodeActAgent(Agent):
'anthropic-beta': 'prompt-caching-2024-07-31',
}
# TODO: move exception handling to agent_controller
try:
response = self.llm.completion(**params)
except OperationCancelled as e:
raise e
except Exception as e:
logger.error(f'{e}')
error_message = '{}: {}'.format(type(e).__name__, str(e).split('\n')[0])
return AgentFinishAction(
thought=f'Agent encountered an error while processing the last action.\nError: {error_message}\nPlease try again.'
)
response = self.llm.completion(**params)
return self.action_parser.parse(response)
+5 -27
View File
@@ -19,44 +19,22 @@ 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 as concise as possible. 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 + EDIT_DIFF_PREFIX + PIP_INSTALL_PREFIX %}
{% set SYSTEM_PREFIX = MINIMAL_SYSTEM_PREFIX + BROWSING_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.
+58 -26
View File
@@ -4,12 +4,23 @@
USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
ASSISTANT:
Sure! Let me write the Python code for starting a web server and save it to a new Python file `app.py`:
<execute_edit>
app.py
<<<<<<< SEARCH
=======
from flask import Flask
Sure! Let me create a Python file `app.py`:
<execute_ipython>
create_file('app.py')
</execute_ipython>
USER:
OBSERVATION:
[File: /workspace/app.py (1 lines total)]
(this is the beginning of the file)
1|
(this is the end of the file)
[File app.py created.]
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
app = Flask(__name__)
@app.route('/')
@@ -18,17 +29,30 @@ def index():
return str(numbers)
if __name__ == '__main__':
app.run(port=5000)
>>>>>>> REPLACE
</execute_edit>
app.run(port=5000)"""
insert_content_at_line(
'app.py',
1,
EDITED_CODE,
)
</execute_ipython>
USER:
OBSERVATION:
I updated the file app.py by
appending:
from flask import Flask
app = Flask(__name__)
(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.]
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:
@@ -153,22 +177,30 @@ 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_edit>
app.py
<<<<<<< SEARCH
return str(numbers)
=======
return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
>>>>>>> REPLACE
</execute_edit>
<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>
USER:
Observation:
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>'
[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.]
ASSISTANT:
Running the updated file:
-1
View File
@@ -256,7 +256,6 @@ def process_instance(
if __name__ == '__main__':
args = parse_arguments()
dataset = load_dataset('RajMaheshwari/Exercism-Python')
dataset = dataset.shuffle(seed=42)
aider_bench_tests = dataset['train'].to_pandas()
llm_config = None
+14 -5
View File
@@ -375,18 +375,27 @@ def reset_logger_for_multiprocessing(
# Remove all existing handlers from logger
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# add back the console handler to print ONE line
logger.addHandler(get_console_handler())
# add console handler to print ONE line
console_handler = get_console_handler(log_level=logging.INFO)
console_handler.setFormatter(
logging.Formatter(
f'Instance {instance_id} - ' + '%(asctime)s - %(levelname)s - %(message)s'
)
)
logger.addHandler(console_handler)
logger.info(
f'Starting evaluation for instance {instance_id}.\n'
f'Hint: run "tail -f {log_file}" to see live logs in a separate shell'
)
# Remove all existing handlers from logger
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Only log WARNING or higher to console
console_handler.setLevel(logging.WARNING)
# Log INFO and above to file
os.makedirs(os.path.dirname(log_file), exist_ok=True)
file_handler = logging.FileHandler(log_file)
file_handler.setFormatter(
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
)
file_handler.setLevel(logging.INFO)
logger.addHandler(file_handler)
+16 -16
View File
@@ -33,7 +33,7 @@
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0",
"tailwind-merge": "^2.5.2",
"vite": "^5.4.7",
"vite": "^5.4.8",
"web-vitals": "^3.5.2"
},
"devDependencies": {
@@ -41,8 +41,8 @@
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.6.1",
"@types/react": "^18.3.8",
"@types/node": "^22.7.0",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -64,7 +64,7 @@
"lint-staged": "^15.2.10",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.12",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^1.6.0"
@@ -4860,9 +4860,9 @@
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
},
"node_modules/@types/node": {
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.6.1.tgz",
"integrity": "sha512-V48tCfcKb/e6cVUigLAaJDAILdMP0fUW6BidkPK4GpGjXcfbnoHasCZDwz3N3yVt5we2RHm4XTQCpv0KJz9zqw==",
"version": "22.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.0.tgz",
"integrity": "sha512-MOdOibwBs6KW1vfqz2uKMlxq5xAfAZ98SZjO8e3XnAbFnTJtAspqhWk7hrdSAs9/Y14ZWMiy7/MxMUzAOadYEw==",
"devOptional": true,
"dependencies": {
"undici-types": "~6.19.2"
@@ -4874,9 +4874,9 @@
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q=="
},
"node_modules/@types/react": {
"version": "18.3.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
"version": "18.3.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.9.tgz",
"integrity": "sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -12426,9 +12426,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz",
"integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==",
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz",
"integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -13112,9 +13112,9 @@
}
},
"node_modules/vite": {
"version": "5.4.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz",
"integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==",
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz",
"integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+4 -4
View File
@@ -32,7 +32,7 @@
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0",
"tailwind-merge": "^2.5.2",
"vite": "^5.4.7",
"vite": "^5.4.8",
"web-vitals": "^3.5.2"
},
"scripts": {
@@ -64,8 +64,8 @@
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.6.1",
"@types/react": "^18.3.8",
"@types/node": "^22.7.0",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -87,7 +87,7 @@
"lint-staged": "^15.2.10",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.12",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^1.6.0"
@@ -7,6 +7,7 @@ import { I18nKey } from "../../../i18n/declaration";
import { AutocompleteCombobox } from "./AutocompleteCombobox";
import { Settings } from "#/services/settings";
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
import { ModelSelector } from "./ModelSelector";
interface SettingsFormProps {
@@ -41,17 +42,29 @@ function SettingsForm({
}: SettingsFormProps) {
const { t } = useTranslation();
const { isOpen: isVisible, onOpenChange: onVisibleChange } = useDisclosure();
const advancedAlreadyInUse = React.useMemo(
() =>
const advancedAlreadyInUse = React.useMemo(() => {
const organizedModels = organizeModelsAndProviders(models);
const { provider, model } = extractModelAndProvider(
settings.LLM_MODEL || "",
);
const isKnownModel =
provider in organizedModels &&
organizedModels[provider].models.includes(model);
return (
!!settings.SECURITY_ANALYZER ||
!!settings.CONFIRMATION_MODE ||
!!settings.LLM_BASE_URL ||
(!!settings.LLM_MODEL && !models.includes(settings.LLM_MODEL)),
[],
);
(!!settings.LLM_MODEL && !isKnownModel)
);
}, [settings, models]);
const [enableAdvanced, setEnableAdvanced] =
React.useState(advancedAlreadyInUse);
React.useEffect(() => {
setEnableAdvanced(advancedAlreadyInUse);
}, [advancedAlreadyInUse]);
const handleAdvancedChange = (value: boolean) => {
setEnableAdvanced(value);
};
-4
View File
@@ -24,10 +24,6 @@ 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.
"""
-4
View File
@@ -10,10 +10,6 @@ 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
"""
+1 -6
View File
@@ -9,11 +9,7 @@ from openhands.events.action.agent import (
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 (
FileEditAction,
FileReadAction,
FileWriteAction,
)
from openhands.events.action.files import FileReadAction, FileWriteAction
from openhands.events.action.message import MessageAction
from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction
@@ -25,7 +21,6 @@ __all__ = [
'BrowseInteractiveAction',
'FileReadAction',
'FileWriteAction',
'FileEditAction',
'AgentFinishAction',
'AgentRejectAction',
'AgentDelegateAction',
-20
View File
@@ -39,23 +39,3 @@ 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}'
+1 -6
View File
@@ -7,11 +7,7 @@ from openhands.events.observation.commands import (
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 (
FileEditObservation,
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.files import FileReadObservation, FileWriteObservation
from openhands.events.observation.observation import Observation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.observation.success import SuccessObservation
@@ -24,7 +20,6 @@ __all__ = [
'BrowserOutputObservation',
'FileReadObservation',
'FileWriteObservation',
'FileEditObservation',
'ErrorObservation',
'AgentStateChangedObservation',
'AgentDelegateObservation',
-27
View File
@@ -26,30 +26,3 @@ 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'
+1 -6
View File
@@ -12,11 +12,7 @@ from openhands.events.action.commands import (
IPythonRunCellAction,
)
from openhands.events.action.empty import NullAction
from openhands.events.action.files import (
FileEditAction,
FileReadAction,
FileWriteAction,
)
from openhands.events.action.files import FileReadAction, FileWriteAction
from openhands.events.action.message import MessageAction
from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction
@@ -28,7 +24,6 @@ actions = (
BrowseInteractiveAction,
FileReadAction,
FileWriteAction,
FileEditAction,
AgentFinishAction,
AgentRejectAction,
AgentDelegateAction,
@@ -7,11 +7,7 @@ from openhands.events.observation.commands import (
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 (
FileEditObservation,
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.files import FileReadObservation, FileWriteObservation
from openhands.events.observation.observation import Observation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.observation.success import SuccessObservation
@@ -23,7 +19,6 @@ observations = (
BrowserOutputObservation,
FileReadObservation,
FileWriteObservation,
FileEditObservation,
AgentDelegateObservation,
SuccessObservation,
ErrorObservation,
+3 -13
View File
@@ -33,7 +33,6 @@ from tenacity import (
)
from openhands.core.exceptions import (
LLMResponseError,
OperationCancelled,
UserCancelledError,
)
@@ -498,10 +497,7 @@ class LLM:
Check the complete documentation at https://litellm.vercel.app/docs/completion
"""
try:
return self._completion
except Exception as e:
raise LLMResponseError(e)
return self._completion
@property
def async_completion(self):
@@ -509,10 +505,7 @@ class LLM:
Check the complete documentation at https://litellm.vercel.app/docs/providers/ollama#example-usage---streaming--acompletion
"""
try:
return self._async_completion
except Exception as e:
raise LLMResponseError(e)
return self._async_completion
@property
def async_streaming_completion(self):
@@ -520,10 +513,7 @@ class LLM:
Check the complete documentation at https://litellm.vercel.app/docs/providers/ollama#example-usage---streaming--acompletion
"""
try:
return self._async_streaming_completion
except Exception as e:
raise LLMResponseError(e)
return self._async_streaming_completion
def vision_is_active(self):
return not self.config.disable_vision and self._supports_vision()
+237 -55
View File
@@ -1,7 +1,12 @@
import datetime
import os
import subprocess
import sys
import time
import docker
from openhands import __version__ as oh_version
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder.base import RuntimeBuilder
@@ -10,45 +15,139 @@ class DockerRuntimeBuilder(RuntimeBuilder):
def __init__(self, docker_client: docker.DockerClient):
self.docker_client = docker_client
def build(self, path: str, tags: list[str]) -> str:
version_info = self.docker_client.version()
server_version = version_info.get('Version', '')
if tuple(map(int, server_version.split('.'))) < (18, 9):
raise RuntimeError('Docker server version must be >= 18.09 to use BuildKit')
self.max_lines = 10
self.log_lines = [''] * self.max_lines
def build(
self,
path: str,
tags: list[str],
use_local_cache: bool = False,
extra_build_args: list[str] | None = None,
) -> str:
"""Builds a Docker image using BuildKit and handles the build logs appropriately.
Args:
path (str): The path to the Docker build context.
tags (list[str]): A list of image tags to apply to the built image.
use_local_cache (bool, optional): Whether to use and update the local build cache. Defaults to True.
extra_build_args (list[str], optional): Additional arguments to pass to the Docker build command. Defaults to None.
Returns:
str: The name of the built Docker image.
Raises:
RuntimeError: If the Docker server version is incompatible or if the build process fails.
Note:
This method uses Docker BuildKit for improved build performance and caching capabilities.
If `use_local_cache` is True, it will attempt to use and update the build cache in a local directory.
The `extra_build_args` parameter allows for passing additional Docker build arguments as needed.
"""
self.docker_client = docker.from_env()
version_info = self.docker_client.version()
server_version = version_info.get('Version', '')
if tuple(map(int, server_version.split('.'))) < (18, 9):
raise RuntimeError('Docker server version must be >= 18.09 to use BuildKit')
target_image_hash_name = tags[0]
target_image_repo, target_image_hash_tag = target_image_hash_name.split(':')
target_image_tag = tags[1].split(':')[1] if len(tags) > 1 else None
try:
build_logs = self.docker_client.api.build(
path=path,
tag=target_image_hash_name,
rm=True,
decode=True,
)
except docker.errors.BuildError as e:
logger.error(f'Sandbox image build failed: {e}')
raise RuntimeError(f'Sandbox image build failed: {e}')
# Check if the image exists and pull if necessary
self.image_exists(target_image_repo)
layers: dict[str, dict[str, str]] = {}
previous_layer_count = 0
for log in build_logs:
if 'stream' in log:
logger.info(log['stream'].strip())
elif 'error' in log:
logger.error(log['error'].strip())
elif 'status' in log:
self._output_build_progress(log, layers, previous_layer_count)
previous_layer_count = len(layers)
else:
logger.info(str(log))
buildx_cmd = [
'docker',
'buildx',
'build',
'--progress=plain',
f'--build-arg=OPENHANDS_RUNTIME_VERSION={oh_version}',
f'--build-arg=OPENHANDS_RUNTIME_BUILD_TIME={datetime.datetime.now().isoformat()}',
f'--tag={target_image_hash_name}',
'--load',
]
cache_dir = '/tmp/.buildx-cache'
if use_local_cache and self._is_cache_usable(cache_dir):
buildx_cmd.extend(
[
f'--cache-from=type=local,src={cache_dir}',
f'--cache-to=type=local,dest={cache_dir},mode=max',
]
)
if extra_build_args:
buildx_cmd.extend(extra_build_args)
buildx_cmd.append(path) # must be last!
print('================ DOCKER BUILD STARTED ================')
if sys.stdout.isatty():
sys.stdout.write('\n' * self.max_lines)
sys.stdout.flush()
try:
process = subprocess.Popen(
buildx_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
)
if process.stdout:
for line in iter(process.stdout.readline, ''):
line = line.strip()
if line:
self._output_logs(line)
return_code = process.wait()
if return_code != 0:
raise subprocess.CalledProcessError(
return_code,
process.args,
output=None,
stderr=None,
)
except subprocess.CalledProcessError as e:
logger.error(f'Image build failed:\n{e}')
logger.error(f'Command output:\n{e.output}')
raise
except subprocess.TimeoutExpired:
logger.error('Image build timed out')
raise
except FileNotFoundError as e:
logger.error(f'Python executable not found: {e}')
raise
except PermissionError as e:
logger.error(
f'Permission denied when trying to execute the build command:\n{e}'
)
raise
except Exception as e:
logger.error(f'An unexpected error occurred during the build process: {e}')
raise
logger.info(f'Image [{target_image_hash_name}] build finished.')
assert (
target_image_tag
), f'Expected target image tag [{target_image_tag}] is None'
image = self.docker_client.images.get(target_image_hash_name)
image.tag(target_image_repo, target_image_tag)
logger.info(
f'Re-tagged image [{target_image_hash_name}] with more generic tag [{target_image_tag}]'
)
if target_image_tag:
image = self.docker_client.images.get(target_image_hash_name)
image.tag(target_image_repo, target_image_tag)
logger.info(
f'Re-tagged image [{target_image_hash_name}] with more generic tag [{target_image_tag}]'
)
# Check if the image is built successfully
image = self.docker_client.images.get(target_image_hash_name)
@@ -80,13 +179,13 @@ class DockerRuntimeBuilder(RuntimeBuilder):
return False
try:
logger.info(f'Checking, if image exists locally:\n{image_name}')
logger.debug(f'Checking, if image exists locally:\n{image_name}')
self.docker_client.images.get(image_name)
logger.info('Image found locally.')
logger.debug('Image found locally.')
return True
except docker.errors.ImageNotFound:
try:
logger.info(
logger.debug(
'Image not found locally. Trying to pull it, please wait...'
)
@@ -97,10 +196,10 @@ class DockerRuntimeBuilder(RuntimeBuilder):
):
self._output_build_progress(line, layers, previous_layer_count)
previous_layer_count = len(layers)
logger.info('Image pulled')
logger.debug('Image pulled')
return True
except docker.errors.ImageNotFound:
logger.info('Could not find image locally or in registry.')
logger.debug('Could not find image locally or in registry.')
return False
except Exception as e:
msg = 'Image could not be pulled: '
@@ -109,9 +208,30 @@ class DockerRuntimeBuilder(RuntimeBuilder):
msg += 'image not found in registry.'
else:
msg += f'{ex_msg}'
logger.warning(msg)
logger.debug(msg)
return False
def _output_logs(self, new_line: str) -> None:
"""Display the last 10 log_lines in the console (not for file logging).
This will create the effect of a rolling display in the console.
'\033[F' moves the cursor up one line.
'\033[2K\r' clears the line and moves the cursor to the beginning of the line.
"""
if not sys.stdout.isatty():
logger.debug(new_line)
return
self.log_lines.pop(0)
self.log_lines.append(new_line[:80])
sys.stdout.write('\033[F' * (self.max_lines))
sys.stdout.flush()
for line in self.log_lines:
sys.stdout.write('\033[2K' + line + '\n')
sys.stdout.flush()
def _output_build_progress(
self, current_line: dict, layers: dict, previous_layer_count: int
) -> None:
@@ -126,31 +246,93 @@ class DockerRuntimeBuilder(RuntimeBuilder):
if 'progress' in current_line:
layers[layer_id]['progress'] = current_line['progress']
if (
'total' in current_line['progressDetail']
and 'current' in current_line['progressDetail']
):
total = current_line['progressDetail']['total']
current = current_line['progressDetail']['current']
percentage = (current / total) * 100
else:
percentage = 0
if 'progressDetail' in current_line:
progress_detail = current_line['progressDetail']
if 'total' in progress_detail and 'current' in progress_detail:
total = progress_detail['total']
current = progress_detail['current']
percentage = min(
(current / total) * 100, 100
) # Ensure it doesn't exceed 100%
else:
percentage = (
100 if layers[layer_id]['status'] == 'Download complete' else 0
)
# refresh process bar in console if stdout is a tty
if sys.stdout.isatty():
sys.stdout.write('\033[F' * previous_layer_count)
for lid, layer_data in sorted(layers.items()):
sys.stdout.write('\033[K')
print(
f'Layer {lid}: {layer_data["progress"]} {layer_data["status"]}'
)
sys.stdout.write('\033[2K\r')
status = layer_data['status']
progress = layer_data['progress']
if status == 'Download complete':
print(f'Layer {lid}: Download complete')
elif status == 'Already exists':
print(f'Layer {lid}: Already exists')
else:
print(f'Layer {lid}: {progress} {status}')
sys.stdout.flush()
# otherwise Log only if percentage is at least 10% higher than last logged
elif percentage != 0 and percentage - layers[layer_id]['last_logged'] >= 10:
logger.info(
elif percentage != 0 and (
percentage - layers[layer_id]['last_logged'] >= 10 or percentage == 100
):
logger.debug(
f'Layer {layer_id}: {layers[layer_id]["progress"]} {layers[layer_id]["status"]}'
)
layers[layer_id]['last_logged'] = percentage
elif 'status' in current_line:
logger.info(current_line['status'])
logger.debug(current_line['status'])
def _prune_old_cache_files(self, cache_dir: str, max_age_days: int = 7) -> None:
"""
Prune cache files older than the specified number of days.
Args:
cache_dir (str): The path to the cache directory.
max_age_days (int): The maximum age of cache files in days.
"""
try:
current_time = time.time()
max_age_seconds = max_age_days * 24 * 60 * 60
for root, _, files in os.walk(cache_dir):
for file in files:
file_path = os.path.join(root, file)
try:
file_age = current_time - os.path.getmtime(file_path)
if file_age > max_age_seconds:
os.remove(file_path)
logger.debug(f'Removed old cache file: {file_path}')
except Exception as e:
logger.warning(f'Error processing cache file {file_path}: {e}')
except Exception as e:
logger.warning(f'Error during build cache pruning: {e}')
def _is_cache_usable(self, cache_dir: str) -> bool:
"""
Check if the cache directory is usable (exists and is writable).
Args:
cache_dir (str): The path to the cache directory.
Returns:
bool: True if the cache directory is usable, False otherwise.
"""
if not os.path.exists(cache_dir):
try:
os.makedirs(cache_dir, exist_ok=True)
logger.debug(f'Created cache directory: {cache_dir}')
except OSError as e:
logger.debug(f'Failed to create cache directory {cache_dir}: {e}')
return False
if not os.access(cache_dir, os.W_OK):
logger.warning(
f'Cache directory {cache_dir} is not writable. Caches will not be used for Docker builds.'
)
return False
self._prune_old_cache_files(cache_dir)
logger.debug(f'Cache directory {cache_dir} is usable')
return True
-60
View File
@@ -28,7 +28,6 @@ from openhands.events.action import (
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
@@ -36,7 +35,6 @@ from openhands.events.action import (
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileEditObservation,
FileReadObservation,
FileWriteObservation,
IPythonRunCellObservation,
@@ -50,11 +48,6 @@ from openhands.runtime.plugins import (
JupyterPlugin,
Plugin,
)
from openhands.runtime.plugins.agent_skills.file_ops import (
append_file,
create_file,
edit_file_by_replace,
)
from openhands.runtime.utils import split_bash_commands
from openhands.runtime.utils.files import insert_lines, read_lines
@@ -69,10 +62,6 @@ INIT_COMMANDS = [
]
SOFT_TIMEOUT_SECONDS = 5
HEAD = '<<<<<<< SEARCH'
DIVIDER = '======='
TAIL = '>>>>>>> REPLACE'
class RuntimeClient:
"""RuntimeClient is running inside docker sandbox.
@@ -520,55 +509,6 @@ class RuntimeClient:
return ErrorObservation(f'Malformed paths not permitted: {filepath}')
return FileWriteObservation(content='', path=filepath)
async def edit(self, action: FileEditAction) -> Observation:
diff_blocks = re.search(
f'(.*)\n{HEAD}(.*)\n{DIVIDER}(.*)\n{TAIL}', action.diff_block, re.DOTALL
)
if not diff_blocks or len(diff_blocks.groups()) < 3:
found_head = re.search(f'{HEAD}', action.diff_block) is not None
found_divider = re.search(f'{DIVIDER}', action.diff_block) is not None
found_tail = re.search(f'{TAIL}', action.diff_block) is not None
error_msg = 'Could not resolve diff block into search/replace blocks.'
if found_head and (not found_tail):
error_msg = 'The diff block got cut off because it is too long. Try breaking it into smaller SEARCH/REPLACE blocks.'
elif found_head and found_tail and (not found_divider):
error_msg = 'Could not find the divider between SEARCH/REPLACE blocks.'
return ErrorObservation(error_msg)
path = diff_blocks.group(1)
search_block = diff_blocks.group(2)
replace_block = diff_blocks.group(3)
if search_block:
search_block = search_block[1:]
if replace_block:
replace_block = replace_block[1:]
working_dir = self._get_working_directory()
filepath = self._resolve_path(path, working_dir)
if not search_block:
create_file(filename=filepath)
append_file(
file_name=filepath,
content=replace_block,
)
else:
if search_block == replace_block:
return ErrorObservation(
'Search block should not be same as Replace block.'
)
edit_file_by_replace(
file_name=filepath,
to_replace=search_block,
new_content=replace_block,
)
return FileEditObservation(
content=action.diff_block,
path=filepath,
search_block=search_block,
replace_block=replace_block,
)
async def browse(self, action: BrowseURLAction) -> Observation:
return await browse(action, self.browser)
-4
View File
@@ -17,7 +17,6 @@ from openhands.events.action import (
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
@@ -452,9 +451,6 @@ class EventStreamRuntime(Runtime):
def write(self, action: FileWriteAction) -> Observation:
return self.run_action(action)
def edit(self, action: FileEditAction) -> Observation:
return self.run_action(action)
def browse(self, action: BrowseURLAction) -> Observation:
return self.run_action(action)
@@ -10,18 +10,9 @@ import_functions(
module=file_reader, function_names=file_reader.__all__, target_globals=globals()
)
__all__ = file_ops.__all__ + file_reader.__all__
__except__ = [
'create_file',
'edit_file_by_replace',
'insert_content_at_line',
'append_file',
] ## DISABLED TEMPORARILY.
DOCUMENTATION = ''
for func_name in __all__:
if func_name in __except__:
continue
func = globals()[func_name]
cur_doc = func.__doc__
@@ -5,8 +5,3 @@ import_functions(
module=file_ops, function_names=file_ops.__all__, target_globals=globals()
)
__all__ = file_ops.__all__
create_file = file_ops.create_file
append_file = file_ops.append_file
edit_file_by_replace = file_ops.edit_file_by_replace
insert_content_at_line = file_ops.insert_content_at_line
+1 -1
View File
@@ -61,7 +61,7 @@ class RemoteRuntime(Runtime):
self.config = config
if self.config.sandbox.api_hostname == 'localhost':
self.config.sandbox.api_hostname = 'api.all-hands.dev/v0/runtime'
logger.warning(
logger.info(
'Using localhost as the API hostname is not supported in the RemoteRuntime. Please set a proper hostname.\n'
'Setting it to default value: api.all-hands.dev/v0/runtime'
)
-5
View File
@@ -14,7 +14,6 @@ from openhands.events.action import (
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
@@ -180,10 +179,6 @@ class Runtime:
def write(self, action: FileWriteAction) -> Observation:
pass
@abstractmethod
def edit(self, action: FileEditAction) -> Observation:
pass
@abstractmethod
def browse(self, action: BrowseURLAction) -> Observation:
pass
+3 -17
View File
@@ -6,11 +6,11 @@ import subprocess
import tempfile
import docker
import toml
from dirhash import dirhash
from jinja2 import Environment, FileSystemLoader
import openhands
from openhands import __version__ as oh_version
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import DockerRuntimeBuilder, RuntimeBuilder
@@ -19,19 +19,6 @@ def get_runtime_image_repo():
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/all-hands-ai/runtime')
def _get_package_version():
"""Read the version from pyproject.toml.
Returns:
- The version specified in pyproject.toml under [tool.poetry]
"""
project_root = os.path.dirname(os.path.dirname(os.path.abspath(openhands.__file__)))
pyproject_path = os.path.join(project_root, 'pyproject.toml')
with open(pyproject_path, 'r') as f:
pyproject_data = toml.load(f)
return pyproject_data['tool']['poetry']['version']
def _put_source_code_to_dir(temp_dir: str):
"""Builds the project source tarball directly in temp_dir and unpacks it.
The OpenHands source code ends up in the temp_dir/code directory.
@@ -46,7 +33,7 @@ def _put_source_code_to_dir(temp_dir: str):
logger.info(f'Building source distribution using project root: {project_root}')
# Fetch the correct version from pyproject.toml
package_version = _get_package_version()
package_version = oh_version
tarball_filename = f'openhands_ai-{package_version}.tar.gz'
tarball_path = os.path.join(temp_dir, tarball_filename)
@@ -188,7 +175,6 @@ def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
if ':' not in base_image:
base_image = base_image + ':latest'
[repo, tag] = base_image.split(':')
oh_version = _get_package_version()
# Hash the repo if it's too long
if len(repo) > 32:
@@ -377,7 +363,7 @@ def _build_sandbox_image(
if not image_name:
raise RuntimeError(f'Build failed for image {target_image_hash_name}')
except Exception as e:
logger.error(f'Sandbox image build failed: {e}')
logger.error(f'Sandbox image build failed: {str(e)}')
raise
return image_name
+22 -3
View File
@@ -8,6 +8,9 @@ from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig, AppConfig, LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action.agent import ChangeAgentStateAction
from openhands.events.event import EventSource
from openhands.events.stream import EventStream
from openhands.runtime import get_runtime_cls
from openhands.runtime.runtime import Runtime
@@ -72,6 +75,19 @@ class AgentSession:
self.thread = Thread(target=self._run, daemon=True)
self.thread.start()
coro = self._start(runtime_name, config, agent, max_iterations, max_budget_per_task, agent_to_llm_config, agent_configs, status_message_callback)
asyncio.run_coroutine_threadsafe(coro, self.loop) # type: ignore
async def _start(self,
runtime_name: str,
config: AppConfig,
agent: Agent,
max_iterations: int,
max_budget_per_task: float | None = None,
agent_to_llm_config: dict[str, LLMConfig] | None = None,
agent_configs: dict[str, AgentConfig] | None = None,
status_message_callback: Optional[Callable] = None,
):
self._create_security_analyzer(config.security.security_analyzer)
self._create_runtime(runtime_name, config, agent, status_message_callback)
self._create_controller(
@@ -82,9 +98,12 @@ class AgentSession:
agent_to_llm_config=agent_to_llm_config,
agent_configs=agent_configs,
)
if self.controller is not None:
self.controller.agent_task = asyncio.run_coroutine_threadsafe(self.controller.start_step_loop(), self.loop) # type: ignore
self.event_stream.add_event(
ChangeAgentStateAction(AgentState.INIT), EventSource.USER
)
if self.controller:
self.controller.agent_task = self.controller.start_step_loop()
await self.controller.agent_task # type: ignore
def _run(self):
asyncio.set_event_loop(self.loop)
-3
View File
@@ -123,9 +123,6 @@ class Session:
f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information..'
)
return
self.agent_session.event_stream.add_event(
ChangeAgentStateAction(AgentState.INIT), EventSource.USER
)
async def on_event(self, event: Event):
"""Callback function for events that mainly come from the agent.
Generated
+27 -27
View File
@@ -571,17 +571,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.25"
version = "1.35.26"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.25-py3-none-any.whl", hash = "sha256:b1cfad301184cdd44dfd4805187ccab12de8dd28dd12a11a5cfdace17918c6de"},
{file = "boto3-1.35.25.tar.gz", hash = "sha256:5df4e2cbe3409db07d3a0d8d63d5220ce3202a78206ad87afdbb41519b26ce45"},
{file = "boto3-1.35.26-py3-none-any.whl", hash = "sha256:c31db992655db233d98762612690cfe60723c9e1503b5709aad92c1c564877bb"},
{file = "boto3-1.35.26.tar.gz", hash = "sha256:b04087afd3570ba540fd293823c77270ec675672af23da9396bd5988a3f8128b"},
]
[package.dependencies]
botocore = ">=1.35.25,<1.36.0"
botocore = ">=1.35.26,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -590,13 +590,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.25"
version = "1.35.26"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.25-py3-none-any.whl", hash = "sha256:e58d60260abf10ccc4417967923117c9902a6a0cff9fddb6ea7ff42dc1bd4630"},
{file = "botocore-1.35.25.tar.gz", hash = "sha256:76c5706b2c6533000603ae8683a297c887abbbaf6ee31e1b2e2863b74b2989bc"},
{file = "botocore-1.35.26-py3-none-any.whl", hash = "sha256:0b9dee5e4a3314e251e103585837506b17fcc7485c3c8adb61a9a913f46da1e7"},
{file = "botocore-1.35.26.tar.gz", hash = "sha256:19efc3a22c9df77960712b4e203f912486f8bcd3794bff0fd7b2a0f5f1d5712d"},
]
[package.dependencies]
@@ -2142,13 +2142,13 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit",
[[package]]
name = "google-ai-generativelanguage"
version = "0.6.9"
version = "0.6.10"
description = "Google Ai Generativelanguage API client library"
optional = false
python-versions = ">=3.7"
files = [
{file = "google_ai_generativelanguage-0.6.9-py3-none-any.whl", hash = "sha256:50360cd80015d1a8cc70952e98560f32fa06ddee2e8e9f4b4b98e431dc561e0b"},
{file = "google_ai_generativelanguage-0.6.9.tar.gz", hash = "sha256:899f1d3a06efa9739f1cd9d2788070178db33c89d4a76f2e8f4da76f649155fa"},
{file = "google_ai_generativelanguage-0.6.10-py3-none-any.whl", hash = "sha256:854a2bf833d18be05ad5ef13c755567b66a4f4a870f099b62c61fe11bddabcf4"},
{file = "google_ai_generativelanguage-0.6.10.tar.gz", hash = "sha256:6fa642c964d8728006fe7e8771026fc0b599ae0ebeaf83caf550941e8e693455"},
]
[package.dependencies]
@@ -2240,13 +2240,13 @@ httplib2 = ">=0.19.0"
[[package]]
name = "google-cloud-aiplatform"
version = "1.67.1"
version = "1.68.0"
description = "Vertex AI API client library"
optional = false
python-versions = ">=3.8"
files = [
{file = "google-cloud-aiplatform-1.67.1.tar.gz", hash = "sha256:701a19061c8c670baa93464ca0b8a1a8720494f802187cef06bc9fcf952db315"},
{file = "google_cloud_aiplatform-1.67.1-py2.py3-none-any.whl", hash = "sha256:2ff0e1794839fcf74d644f3f54ff2de5d8099b3e388edecc48f6d620c1f3582c"},
{file = "google-cloud-aiplatform-1.68.0.tar.gz", hash = "sha256:d74e9f33707c7a14c6a32a7cfe9acd32b90975dfba9fac487d105c8ba5197f40"},
{file = "google_cloud_aiplatform-1.68.0-py2.py3-none-any.whl", hash = "sha256:24dacc34457665ab6054bdf47e2475793dcf2d865b568420a909b452a477b3e6"},
]
[package.dependencies]
@@ -2277,7 +2277,7 @@ pipelines = ["pyyaml (>=5.3.1,<7)"]
prediction = ["docker (>=5.0.3)", "fastapi (>=0.71.0,<=0.114.0)", "httpx (>=0.23.0,<0.25.0)", "starlette (>=0.17.1)", "uvicorn[standard] (>=0.16.0)"]
private-endpoints = ["requests (>=2.28.1)", "urllib3 (>=1.21.1,<1.27)"]
ray = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "setuptools (<70.0.0)"]
ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "ray[train] (==2.9.3)", "scikit-learn", "setuptools (<70.0.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost-ray"]
ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "ray[train]", "scikit-learn", "setuptools (<70.0.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost-ray"]
reasoningengine = ["cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.6.3,<3)"]
tensorboard = ["tensorboard-plugin-profile (>=2.4.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "werkzeug (>=2.0.0,<2.1.0dev)"]
testing = ["bigframes", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "nltk", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "sentencepiece (>=0.2.0)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<3.0.0dev)", "tensorflow (==2.13.0)", "tensorflow (==2.16.1)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "torch (>=2.0.0,<2.1.0)", "torch (>=2.2.0)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)", "xgboost"]
@@ -2457,16 +2457,16 @@ testing = ["pytest"]
[[package]]
name = "google-generativeai"
version = "0.8.1"
version = "0.8.2"
description = "Google Generative AI High level API client library and tools."
optional = false
python-versions = ">=3.9"
files = [
{file = "google_generativeai-0.8.1-py3-none-any.whl", hash = "sha256:b031877f24d51af0945207657c085896a0a886eceec7a1cb7029327b0aa6e2f6"},
{file = "google_generativeai-0.8.2-py3-none-any.whl", hash = "sha256:fabc0e2e8d2bfb6fdb1653e91dba83fecb2a2a6878883b80017def90fda8032d"},
]
[package.dependencies]
google-ai-generativelanguage = "0.6.9"
google-ai-generativelanguage = "0.6.10"
google-api-core = "*"
google-api-python-client = "*"
google-auth = ">=2.15.0"
@@ -3762,13 +3762,13 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.48.0"
version = "1.48.1"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
files = [
{file = "litellm-1.48.0-py3-none-any.whl", hash = "sha256:7765e8a92069778f5fc66aacfabd0e2f8ec8d74fb117f5e475567d89b0d376b9"},
{file = "litellm-1.48.0.tar.gz", hash = "sha256:31a9b8a25a9daf44c24ddc08bf74298da920f2c5cea44135e5061278d0aa6fc9"},
{file = "litellm-1.48.1-py3-none-any.whl", hash = "sha256:a3a03b31ba8b8eafda2b7f58c5cf3a1dfff091d2158df26a5ec08fc48a8df06e"},
{file = "litellm-1.48.1.tar.gz", hash = "sha256:6bf2282a517835e137ff1de854f1be335416f2a671e20a1cfef51d009bf01493"},
]
[package.dependencies]
@@ -3805,19 +3805,19 @@ pydantic = ">=1.10"
[[package]]
name = "llama-index"
version = "0.11.12"
version = "0.11.13"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.8.1"
files = [
{file = "llama_index-0.11.12-py3-none-any.whl", hash = "sha256:a7d0b4065df2689cec1baeab9bfaed4d94e4ddc7e941df2ee47abfb218ce3ea1"},
{file = "llama_index-0.11.12.tar.gz", hash = "sha256:6b9220bf4c76a4ac0a82ccc642c3ea94f51381a9718ac601021f2fa95b74aab1"},
{file = "llama_index-0.11.13-py3-none-any.whl", hash = "sha256:9979153bed81e41c9dd9abe4b5ab136a582b9028aac5357100f13b921b6bc432"},
{file = "llama_index-0.11.13.tar.gz", hash = "sha256:e15b30e3786aacd39a426b4d3bd3829bf7bb8b3fbafc08c737ff14a815992342"},
]
[package.dependencies]
llama-index-agent-openai = ">=0.3.4,<0.4.0"
llama-index-cli = ">=0.3.1,<0.4.0"
llama-index-core = ">=0.11.11,<0.12.0"
llama-index-core = ">=0.11.13,<0.12.0"
llama-index-embeddings-openai = ">=0.2.4,<0.3.0"
llama-index-indices-managed-llama-cloud = ">=0.3.0"
llama-index-legacy = ">=0.9.48,<0.10.0"
@@ -3863,13 +3863,13 @@ llama-index-llms-openai = ">=0.2.0,<0.3.0"
[[package]]
name = "llama-index-core"
version = "0.11.12"
version = "0.11.13.post1"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.8.1"
files = [
{file = "llama_index_core-0.11.12-py3-none-any.whl", hash = "sha256:7dc7ead649bac8f09e61c6c8bf93d257f68a7315223552421be4f0ffc3a8054d"},
{file = "llama_index_core-0.11.12.tar.gz", hash = "sha256:ce2dd037ff889d9ea6b25872228cc9de614c10445d19377f6ae5c66b93a50c61"},
{file = "llama_index_core-0.11.13.post1-py3-none-any.whl", hash = "sha256:b71021ac86644898e723098a38bfc5f5b98bccb89e194854610b94ce6e3caeec"},
{file = "llama_index_core-0.11.13.post1.tar.gz", hash = "sha256:2f2041cc176398f71be449e5aadcad261236fcbfdde9deb7d396512aecb3428a"},
]
[package.dependencies]
+260 -10
View File
@@ -1,15 +1,19 @@
import os
import tempfile
import uuid
from importlib.metadata import version
from unittest.mock import ANY, MagicMock, call, patch
import docker
import pytest
import toml
from pytest import TempPathFactory
from openhands import __version__ as oh_version
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder.docker import DockerRuntimeBuilder
from openhands.runtime.utils.runtime_build import (
_generate_dockerfile,
_get_package_version,
_put_source_code_to_dir,
build_runtime_image,
get_runtime_image_repo,
@@ -17,7 +21,8 @@ from openhands.runtime.utils.runtime_build import (
prep_docker_build_folder,
)
OH_VERSION = f'oh_v{_get_package_version()}'
OH_VERSION = f'oh_v{oh_version}'
DEFAULT_BASE_IMAGE = 'nikolaik/python-nodejs:python3.11-nodejs22'
@pytest.fixture
@@ -25,6 +30,21 @@ def temp_dir(tmp_path_factory: TempPathFactory) -> str:
return str(tmp_path_factory.mktemp('test_runtime_build'))
@pytest.fixture
def mock_docker_client():
mock_client = MagicMock(spec=docker.DockerClient)
mock_client.version.return_value = {
'Version': '19.03'
} # Ensure version is >= 18.09
return mock_client
@pytest.fixture
def docker_runtime_builder():
client = docker.from_env()
return DockerRuntimeBuilder(client)
def _check_source_code_in_dir(temp_dir):
# assert there is a folder called 'code' in the temp_dir
code_dir = os.path.join(temp_dir, 'code')
@@ -63,7 +83,7 @@ def test_put_source_code_to_dir(temp_dir):
def test_docker_build_folder(temp_dir):
prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
base_image=DEFAULT_BASE_IMAGE,
skip_init=False,
)
@@ -82,14 +102,14 @@ def test_docker_build_folder(temp_dir):
def test_hash_folder_same(temp_dir):
dir_hash_1 = prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
base_image=DEFAULT_BASE_IMAGE,
skip_init=False,
)
with tempfile.TemporaryDirectory() as temp_dir_2:
dir_hash_2 = prep_docker_build_folder(
temp_dir_2,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
base_image=DEFAULT_BASE_IMAGE,
skip_init=False,
)
assert dir_hash_1 == dir_hash_2
@@ -98,14 +118,14 @@ def test_hash_folder_same(temp_dir):
def test_hash_folder_diff_init(temp_dir):
dir_hash_1 = prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
base_image=DEFAULT_BASE_IMAGE,
skip_init=False,
)
with tempfile.TemporaryDirectory() as temp_dir_2:
dir_hash_2 = prep_docker_build_folder(
temp_dir_2,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
base_image=DEFAULT_BASE_IMAGE,
skip_init=True,
)
assert dir_hash_1 != dir_hash_2
@@ -114,7 +134,7 @@ def test_hash_folder_diff_init(temp_dir):
def test_hash_folder_diff_image(temp_dir):
dir_hash_1 = prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
base_image=DEFAULT_BASE_IMAGE,
skip_init=False,
)
@@ -179,8 +199,7 @@ def test_get_runtime_image_repo_and_tag_eventstream():
and img_tag == f'{OH_VERSION}_image_debian_tag_11'
)
base_image = 'nikolaik/python-nodejs:python3.11-nodejs22'
img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
img_repo, img_tag = get_runtime_image_repo_and_tag(DEFAULT_BASE_IMAGE)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag
@@ -290,3 +309,234 @@ def test_build_runtime_image_exact_hash_not_exist(mock_build_sandbox_image, temp
target_image_tag=latest_image_tag,
)
assert image_name == f'{repo}:{from_scratch_hash}'
# ==============================
# DockerRuntimeBuilder Tests
# ==============================
def test_output_progress(docker_runtime_builder):
with patch('sys.stdout.isatty', return_value=True):
with patch('sys.stdout.write') as mock_write, patch('sys.stdout.flush'):
docker_runtime_builder._output_logs('new log line')
mock_write.assert_any_call('\033[F' * 10)
mock_write.assert_any_call('\033[2Knew log line\n')
def test_output_build_progress(docker_runtime_builder):
with patch('sys.stdout.isatty', return_value=True):
with patch('sys.stdout.write') as mock_write, patch('sys.stdout.flush'):
layers = {}
docker_runtime_builder._output_build_progress(
{
'id': 'layer1',
'status': 'Downloading',
'progressDetail': {'current': 50, 'total': 100},
},
layers,
0,
)
mock_write.assert_any_call('\033[F' * 0)
mock_write.assert_any_call('\033[2K\r')
assert layers['layer1']['status'] == 'Downloading'
assert layers['layer1']['progress'] == ''
assert layers['layer1']['last_logged'] == 50.0
@pytest.fixture(scope='function')
def live_docker_image():
client = docker.from_env()
unique_id = str(uuid.uuid4())[:8] # Use first 8 characters of a UUID
unique_prefix = f'test_image_{unique_id}'
dockerfile_content = f"""
# syntax=docker/dockerfile:1.4
FROM {DEFAULT_BASE_IMAGE} AS base
RUN apt-get update && apt-get install -y wget sudo apt-utils
FROM base AS intermediate
RUN mkdir -p /openhands
FROM intermediate AS final
RUN echo "Hello, OpenHands!" > /openhands/hello.txt
"""
with tempfile.TemporaryDirectory() as temp_dir:
dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
with open(dockerfile_path, 'w') as f:
f.write(dockerfile_content)
try:
image, logs = client.images.build(
path=temp_dir,
tag=f'{unique_prefix}:final',
buildargs={'DOCKER_BUILDKIT': '1'},
labels={'test': 'true'},
rm=True,
forcerm=True,
)
# Tag intermediary stages
client.api.tag(image.id, unique_prefix, 'base')
client.api.tag(image.id, unique_prefix, 'intermediate')
all_tags = [
f'{unique_prefix}:final',
f'{unique_prefix}:base',
f'{unique_prefix}:intermediate',
]
print(f'\nImage ID: {image.id}')
print(f'Image tags: {all_tags}\n')
yield image
finally:
# Clean up all tagged images
for tag in all_tags:
try:
client.images.remove(tag, force=True)
print(f'Removed image: {tag}')
except Exception as e:
print(f'Error removing image {tag}: {str(e)}')
def test_init(docker_runtime_builder):
assert isinstance(docker_runtime_builder.docker_client, docker.DockerClient)
assert docker_runtime_builder.max_lines == 10
assert docker_runtime_builder.log_lines == [''] * 10
def test_build_image_from_scratch(docker_runtime_builder, tmp_path):
context_path = str(tmp_path)
tags = ['test_build:latest']
# Create a minimal Dockerfile in the context path
with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
f.write("""FROM php:latest
CMD ["sh", "-c", "echo 'Hello, World!'"]
""")
built_image_name = None
container = None
client = docker.from_env()
try:
with patch('sys.stdout.isatty', return_value=False):
built_image_name = docker_runtime_builder.build(
context_path,
tags,
use_local_cache=False,
)
assert built_image_name == f'{tags[0]}'
# Verify the image was created
image = client.images.get(tags[0])
assert image is not None
except docker.errors.ImageNotFound:
pytest.fail('test_build_image_from_scratch: test image not found!')
except Exception as e:
pytest.fail(f'test_build_image_from_scratch: Build failed with error: {str(e)}')
finally:
# Clean up the container
if container:
try:
container.remove(force=True)
logger.info(f'Removed test container: `{container.id}`')
except Exception as e:
logger.warning(
f'Failed to remove test container `{container.id}`: {str(e)}'
)
# Clean up the image
if built_image_name:
try:
client.images.remove(built_image_name, force=True)
logger.info(f'Removed test image: `{built_image_name}`')
except Exception as e:
logger.warning(
f'Failed to remove test image `{built_image_name}`: {str(e)}'
)
else:
logger.warning('No image was built, so no image cleanup was necessary.')
def _format_size_to_gb(bytes_size):
"""Convert bytes to gigabytes with two decimal places."""
return round(bytes_size / (1024**3), 2)
def test_list_dangling_images():
client = docker.from_env()
dangling_images = client.images.list(filters={'dangling': True})
if dangling_images and len(dangling_images) > 0:
for image in dangling_images:
if 'Size' in image.attrs and isinstance(image.attrs['Size'], int):
size_gb = _format_size_to_gb(image.attrs['Size'])
logger.info(f'Dangling image: {image.tags}, Size: {size_gb} GB')
else:
logger.info(f'Dangling image: {image.tags}, Size: n/a')
else:
logger.info('No dangling images found')
def test_build_image_from_repo(docker_runtime_builder, tmp_path):
context_path = str(tmp_path)
tags = ['alpine:latest']
# Create a minimal Dockerfile in the context path
with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
f.write(f"""FROM {DEFAULT_BASE_IMAGE}
CMD ["sh", "-c", "echo 'Hello, World!'"]
""")
built_image_name = None
container = None
client = docker.from_env()
try:
with patch('sys.stdout.isatty', return_value=False):
built_image_name = docker_runtime_builder.build(
context_path,
tags,
use_local_cache=False,
)
assert built_image_name == f'{tags[0]}'
image = client.images.get(tags[0])
assert image is not None
except docker.errors.ImageNotFound:
pytest.fail('test_build_image_from_repo: test image not found!')
finally:
# Clean up the container
if container:
try:
container.remove(force=True)
logger.info(f'Removed test container: `{container.id}`')
except Exception as e:
logger.warning(
f'Failed to remove test container `{container.id}`: {str(e)}'
)
# Clean up the image
if built_image_name:
try:
client.images.remove(built_image_name, force=True)
logger.info(f'Removed test image: `{built_image_name}`')
except Exception as e:
logger.warning(
f'Failed to remove test image `{built_image_name}`: {str(e)}'
)
else:
logger.warning('No image was built, so no image cleanup was necessary.')
def test_image_exists_local(docker_runtime_builder, live_docker_image):
image_name = live_docker_image.tags[0] if live_docker_image.tags else None
assert image_name, 'Image has no tags'
assert docker_runtime_builder.image_exists(image_name)
def test_image_exists_not_found(docker_runtime_builder):
assert not docker_runtime_builder.image_exists('nonexistent:image')