mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e79cd12a6 | |||
| 2cc1c3ef42 | |||
| ef0b08a46e | |||
| f1d5202884 | |||
| 11cedfb854 | |||
| 6d103a0db2 | |||
| 798aaeaef6 | |||
| 0df4b97e5b | |||
| 81b3cd71b3 | |||
| 9241ae2148 | |||
| d3f86e052a | |||
| e0c65f8f9c | |||
| 394ab360a8 | |||
| 8a146d5ced |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+16
-16
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user