mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
106 Commits
hotfix/set
...
eval/visua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532a284d5c | ||
|
|
43f6104967 | ||
|
|
e619929909 | ||
|
|
93753ac2e0 | ||
|
|
38e19d214d | ||
|
|
19a4f1c3ec | ||
|
|
45a048f9e3 | ||
|
|
358d9cb3f4 | ||
|
|
e6a2fd3fd4 | ||
|
|
c2f308f397 | ||
|
|
a1f1c802d9 | ||
|
|
ad2237d7dd | ||
|
|
aa0cd51967 | ||
|
|
081a1305f0 | ||
|
|
9912e28576 | ||
|
|
b19a33ccad | ||
|
|
21e912d6fb | ||
|
|
0dd9b95dbe | ||
|
|
aebb583779 | ||
|
|
2ff9ba1229 | ||
|
|
a7e6068ba8 | ||
|
|
24adcee9e3 | ||
|
|
21d4ba0bbd | ||
|
|
5ba9a6d321 | ||
|
|
aa223734d4 | ||
|
|
053723a4d4 | ||
|
|
5a6dbac5a3 | ||
|
|
93d74e9b41 | ||
|
|
1337d03816 | ||
|
|
04e36df4d7 | ||
|
|
f9ba16b648 | ||
|
|
f0dbb02ee1 | ||
|
|
318c811817 | ||
|
|
b468150f2a | ||
|
|
b9a3f1c753 | ||
|
|
09e8a1eeba | ||
|
|
ff3880c76d | ||
|
|
8bd7613724 | ||
|
|
5b7fcfbe1a | ||
|
|
8ae36481df | ||
|
|
25fdb0c3bf | ||
|
|
7f57dbebda | ||
|
|
54589d7e83 | ||
|
|
b7f34c3f8d | ||
|
|
210eeee94a | ||
|
|
509892cf0e | ||
|
|
89963e93d8 | ||
|
|
b6804f9e1e | ||
|
|
d30211da18 | ||
|
|
06121bf20f | ||
|
|
541a445dfc | ||
|
|
03e496fb60 | ||
|
|
1b6e444ecb | ||
|
|
2b04ee2e62 | ||
|
|
4383be1ab4 | ||
|
|
b4d20e3e18 | ||
|
|
532c7cdf02 | ||
|
|
987861b5e7 | ||
|
|
f07ec7a09c | ||
|
|
b1fa6301f0 | ||
|
|
62fbe4c622 | ||
|
|
a1a87af69d | ||
|
|
a12087243a | ||
|
|
899c1f8360 | ||
|
|
c5d7caf01f | ||
|
|
85a760e561 | ||
|
|
8e9c315729 | ||
|
|
000055ba73 | ||
|
|
2edb2337c2 | ||
|
|
7c8a0162ae | ||
|
|
c10f18b3bd | ||
|
|
313c8eca20 | ||
|
|
eff9e07272 | ||
|
|
f8a3aeccd6 | ||
|
|
9375e0d756 | ||
|
|
72af7bbba2 | ||
|
|
0661c69bd3 | ||
|
|
0c961bfd8b | ||
|
|
0bed17758f | ||
|
|
da1a6035ac | ||
|
|
df050e4786 | ||
|
|
e211647eba | ||
|
|
8579710c82 | ||
|
|
8c35150c3e | ||
|
|
578291e961 | ||
|
|
efe04baf34 | ||
|
|
6e089619e0 | ||
|
|
179a89a211 | ||
|
|
8795ee6c6e | ||
|
|
97e938d545 | ||
|
|
b9a70c8d5c | ||
|
|
082d0b25c5 | ||
|
|
c5797d1d5a | ||
|
|
7ce1fb85ff | ||
|
|
fa6792e5a6 | ||
|
|
3d9b4c4af6 | ||
|
|
e21cbf67ee | ||
|
|
6b2e3f938f | ||
|
|
580d7b938c | ||
|
|
28178a2940 | ||
|
|
04382b2b19 | ||
|
|
e249b920ff | ||
|
|
d920a69f69 | ||
|
|
a8ce888981 | ||
|
|
e22ddc0dd6 | ||
|
|
c370912f12 |
110
.github/workflows/integration-runner.yml
vendored
110
.github/workflows/integration-runner.yml
vendored
@@ -56,6 +56,7 @@ jobs:
|
||||
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 10
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
@@ -70,7 +71,7 @@ jobs:
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' $N_PROCESSES '' 'haiku_run'
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
|
||||
|
||||
# get integration tests report
|
||||
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
|
||||
@@ -88,6 +89,7 @@ jobs:
|
||||
LLM_MODEL: "litellm_proxy/deepseek-chat"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 10
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
@@ -99,7 +101,7 @@ jobs:
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' $N_PROCESSES '' 'deepseek_run'
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
|
||||
|
||||
# get integration tests report
|
||||
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
|
||||
@@ -109,11 +111,104 @@ jobs:
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Run DelegatorAgent tests for Haiku, limited to t01 and t02
|
||||
- name: Wait a little bit (again)
|
||||
run: sleep 5
|
||||
|
||||
- name: Configure config.toml for testing DelegatorAgent (Haiku)
|
||||
env:
|
||||
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 30
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
|
||||
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
|
||||
echo "temperature = 0.0" >> config.toml
|
||||
|
||||
- name: Run integration test evaluation for DelegatorAgent (Haiku)
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD DelegatorAgent '' 30 $N_PROCESSES "t01_fix_simple_typo,t02_add_bash_hello" 'delegator_haiku_run'
|
||||
|
||||
# Find and export the delegator test results
|
||||
REPORT_FILE_DELEGATOR_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/DelegatorAgent/*haiku*_maxiter_30_N* -name "report.md" -type f | head -n 1)
|
||||
echo "REPORT_FILE_DELEGATOR_HAIKU: $REPORT_FILE_DELEGATOR_HAIKU"
|
||||
echo "INTEGRATION_TEST_REPORT_DELEGATOR_HAIKU<<EOF" >> $GITHUB_ENV
|
||||
cat $REPORT_FILE_DELEGATOR_HAIKU >> $GITHUB_ENV
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Run DelegatorAgent tests for DeepSeek, limited to t01 and t02
|
||||
- name: Wait a little bit (again)
|
||||
run: sleep 5
|
||||
|
||||
- name: Configure config.toml for testing DelegatorAgent (DeepSeek)
|
||||
env:
|
||||
LLM_MODEL: "litellm_proxy/deepseek-chat"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 30
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
|
||||
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
|
||||
echo "temperature = 0.0" >> config.toml
|
||||
- name: Run integration test evaluation for DelegatorAgent (DeepSeek)
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD DelegatorAgent '' 30 $N_PROCESSES "t01_fix_simple_typo,t02_add_bash_hello" 'delegator_deepseek_run'
|
||||
|
||||
# Find and export the delegator test results
|
||||
REPORT_FILE_DELEGATOR_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/DelegatorAgent/deepseek*_maxiter_30_N* -name "report.md" -type f | head -n 1)
|
||||
echo "REPORT_FILE_DELEGATOR_DEEPSEEK: $REPORT_FILE_DELEGATOR_DEEPSEEK"
|
||||
echo "INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK<<EOF" >> $GITHUB_ENV
|
||||
cat $REPORT_FILE_DELEGATOR_DEEPSEEK >> $GITHUB_ENV
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
# -------------------------------------------------------------
|
||||
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
|
||||
- name: Wait a little bit (again)
|
||||
run: sleep 5
|
||||
|
||||
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
|
||||
env:
|
||||
LLM_MODEL: "litellm_proxy/deepseek-chat"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 15
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
|
||||
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
|
||||
echo "temperature = 0.0" >> config.toml
|
||||
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
|
||||
|
||||
# Find and export the visual browsing agent test results
|
||||
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
|
||||
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
|
||||
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
|
||||
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create archive of evaluation outputs
|
||||
run: |
|
||||
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
|
||||
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
|
||||
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* # Only include the actual result directories
|
||||
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
|
||||
|
||||
- name: Upload evaluation results as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -154,5 +249,14 @@ jobs:
|
||||
**Integration Tests Report (DeepSeek)**
|
||||
DeepSeek LLM Test Results:
|
||||
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
|
||||
---
|
||||
**Integration Tests Report Delegator (Haiku)**
|
||||
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_HAIKU }}
|
||||
---
|
||||
**Integration Tests Report Delegator (DeepSeek)**
|
||||
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK }}
|
||||
---
|
||||
**Integration Tests Report VisualBrowsing (DeepSeek)**
|
||||
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
|
||||
---
|
||||
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})
|
||||
|
||||
12
.github/workflows/openhands-resolver.yml
vendored
12
.github/workflows/openhands-resolver.yml
vendored
@@ -84,6 +84,10 @@ jobs:
|
||||
run: |
|
||||
python -m pip index versions openhands-ai > openhands_versions.txt
|
||||
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
|
||||
# Ensure requirements.txt ends with newline before appending
|
||||
if [ -f requirements.txt ] && [ -s requirements.txt ]; then
|
||||
sed -i -e '$a\' requirements.txt
|
||||
fi
|
||||
echo "openhands-ai==${OPENHANDS_VERSION}" >> requirements.txt
|
||||
cat requirements.txt
|
||||
|
||||
@@ -184,6 +188,7 @@ jobs:
|
||||
});
|
||||
|
||||
- name: Install OpenHands
|
||||
id: install_openhands
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body || '' }}
|
||||
@@ -196,7 +201,6 @@ jobs:
|
||||
const reviewBody = process.env.REVIEW_BODY.trim();
|
||||
const labelName = process.env.LABEL_NAME.trim();
|
||||
const eventName = process.env.EVENT_NAME.trim();
|
||||
|
||||
// Check conditions
|
||||
const isExperimentalLabel = labelName === "fix-me-experimental";
|
||||
const isIssueCommentExperimental =
|
||||
@@ -205,6 +209,9 @@ jobs:
|
||||
const isReviewCommentExperimental =
|
||||
eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
|
||||
|
||||
// Set output variable
|
||||
core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
|
||||
|
||||
// Perform package installation
|
||||
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
|
||||
console.log("Installing experimental OpenHands...");
|
||||
@@ -230,7 +237,8 @@ jobs:
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--issue-type ${{ env.ISSUE_TYPE }} \
|
||||
--max-iterations ${{ env.MAX_ITERATIONS }} \
|
||||
--comment-id ${{ env.COMMENT_ID }}
|
||||
--comment-id ${{ env.COMMENT_ID }} \
|
||||
--is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
|
||||
|
||||
- name: Check resolution result
|
||||
id: check_result
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -176,6 +176,7 @@ evaluation/gorilla/data
|
||||
evaluation/toolqa/data
|
||||
evaluation/scienceagentbench/benchmark
|
||||
evaluation/commit0_bench/repos
|
||||
evaluation/visualcodebench/
|
||||
|
||||
# openhands resolver
|
||||
output/
|
||||
|
||||
@@ -113,6 +113,20 @@ individual, or aggression toward or disparagement of classes of individuals.
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
### Slack and Discord Etiquettes
|
||||
|
||||
These Slack and Discord etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Let’s work together to build a supportive and welcoming community!
|
||||
|
||||
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
|
||||
- Use threads for specific discussions to keep channels organized and easier to follow.
|
||||
- Tag others only when their input is critical or urgent, and use @here, @channel or @everyone sparingly to minimize disruptions.
|
||||
- Be patient, as open-source contributors and maintainers often have other commitments and may need time to respond.
|
||||
- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://app.slack.com/client/T06P212QSEA/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions, [discord - #general](https://discord.com/channels/1222935860639563850/1222935861386018885)).
|
||||
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
|
||||
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
|
||||
- Always adhere to [our standards](https://github.com/All-Hands-AI/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
|
||||
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if it’s too busy, but set notifications to alert you only when “LLMs” appears in messages. Also for Discord, go to the channel notifications and choose the option that best describes your need.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
|
||||
@@ -5,7 +5,7 @@ Otherwise, you can clone the OpenHands project directly.
|
||||
|
||||
## Start the Server for Development
|
||||
### 1. Requirements
|
||||
* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu <= 22.04]
|
||||
* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
|
||||
* [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
|
||||
* [Python](https://www.python.org/downloads/) = 3.12
|
||||
* [NodeJS](https://nodejs.org/en/download/package-manager) >= 20.x
|
||||
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
|
||||
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.20-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.21-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -39,21 +39,21 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or jump to the [
|
||||
## ⚡ Quick Start
|
||||
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
See the [Installation](https://docs.all-hands.dev/modules/usage/installation) guide for
|
||||
See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installation) guide for
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
@@ -69,7 +69,7 @@ run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules
|
||||
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
|
||||
or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
Visit [Running OpenHands](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
> [!CAUTION]
|
||||
> OpenHands is meant to be run by a single user on their local workstation.
|
||||
|
||||
@@ -23,6 +23,9 @@ workspace_base = "./workspace"
|
||||
# Cache directory path
|
||||
#cache_dir = "/tmp/cache"
|
||||
|
||||
# Reasoning effort for o1 models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Debugging enabled
|
||||
#debug = false
|
||||
|
||||
@@ -36,6 +39,11 @@ workspace_base = "./workspace"
|
||||
# If it's a folder, the session id will be used as the file name
|
||||
#save_trajectory_path="./trajectories"
|
||||
|
||||
# Path to replay a trajectory, must be a file path
|
||||
# If provided, trajectory will be loaded and replayed before the
|
||||
# agent responds to any user instruction
|
||||
#replay_trajectory_path = ""
|
||||
|
||||
# File store path
|
||||
#file_store_path = "/tmp/file_store"
|
||||
|
||||
@@ -67,7 +75,7 @@ workspace_base = "./workspace"
|
||||
#run_as_openhands = true
|
||||
|
||||
# Runtime environment
|
||||
#runtime = "eventstream"
|
||||
#runtime = "docker"
|
||||
|
||||
# Name of the default agent
|
||||
#default_agent = "CodeActAgent"
|
||||
@@ -220,8 +228,8 @@ codeact_enable_jupyter = true
|
||||
# LLM config group to use
|
||||
#llm_config = 'your-llm-config-group'
|
||||
|
||||
# Whether to use microagents at all
|
||||
#use_microagents = true
|
||||
# Whether to use prompt extension (e.g., microagent, repo/runtime info) at all
|
||||
#enable_prompt_extensions = true
|
||||
|
||||
# List of microagents to disable
|
||||
#disabled_microagents = []
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.20-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.21-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
# Options de configuration
|
||||
|
||||
Ce guide détaille toutes les options de configuration disponibles pour OpenHands, vous aidant à personnaliser son comportement et à l'intégrer avec d'autres services.
|
||||
@@ -184,6 +182,10 @@ Les options de configuration LLM (Large Language Model) sont définies dans la s
|
||||
|
||||
Pour les utiliser avec la commande docker, passez `-e LLM_<option>`. Exemple : `-e LLM_NUM_RETRIES`.
|
||||
|
||||
:::note
|
||||
Pour les configurations de développement, vous pouvez également définir des configurations LLM personnalisées. Voir [Configurations LLM personnalisées](./llms/custom-llm-configs) pour plus de détails.
|
||||
:::
|
||||
|
||||
**Informations d'identification AWS**
|
||||
- `aws_access_key_id`
|
||||
- Type : `str`
|
||||
@@ -368,4 +370,26 @@ Les options de configuration de l'agent sont définies dans les sections `[agent
|
||||
- `codeact_enable_llm_editor`
|
||||
- Type : `bool`
|
||||
- Valeur par défaut : `false`
|
||||
- Description : Si l'éditeur LLM est activé dans l'espace d'action (foncti
|
||||
- Description : Si l'éditeur LLM est activé dans l'espace d'action (fonctionne uniquement avec l'appel de fonction)
|
||||
|
||||
**Utilisation du micro-agent**
|
||||
- `enable_prompt_extensions`
|
||||
- Type : `bool`
|
||||
- Valeur par défaut : `true`
|
||||
- Description : Indique si l'utilisation des micro-agents est activée ou non
|
||||
|
||||
- `disabled_microagents`
|
||||
- Type : `list of str`
|
||||
- Valeur par défaut : `None`
|
||||
- Description : Liste des micro-agents à désactiver
|
||||
|
||||
### Exécution
|
||||
- `timeout`
|
||||
- Type : `int`
|
||||
- Valeur par défaut : `120`
|
||||
- Description : Délai d'expiration du bac à sable, en secondes
|
||||
|
||||
- `user_id`
|
||||
- Type : `int`
|
||||
- Valeur par défaut : `1000`
|
||||
- Description : ID de l'utilisateur du bac à sable
|
||||
|
||||
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -61,7 +61,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -56,6 +56,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
La façon la plus simple d'exécuter OpenHands est avec Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Configurations LLM personnalisées
|
||||
|
||||
OpenHands permet de définir plusieurs configurations LLM nommées dans votre fichier `config.toml`. Cette fonctionnalité vous permet d'utiliser différentes configurations LLM pour différents usages, comme utiliser un modèle moins coûteux pour les tâches qui ne nécessitent pas de réponses de haute qualité, ou utiliser différents modèles avec différents paramètres pour des agents spécifiques.
|
||||
|
||||
## Comment ça fonctionne
|
||||
|
||||
Les configurations LLM nommées sont définies dans le fichier `config.toml` en utilisant des sections qui commencent par `llm.`. Par exemple :
|
||||
|
||||
```toml
|
||||
# Configuration LLM par défaut
|
||||
[llm]
|
||||
model = "gpt-4"
|
||||
api_key = "votre-clé-api"
|
||||
temperature = 0.0
|
||||
|
||||
# Configuration LLM personnalisée pour un modèle moins coûteux
|
||||
[llm.gpt3]
|
||||
model = "gpt-3.5-turbo"
|
||||
api_key = "votre-clé-api"
|
||||
temperature = 0.2
|
||||
|
||||
# Une autre configuration personnalisée avec des paramètres différents
|
||||
[llm.haute-creativite]
|
||||
model = "gpt-4"
|
||||
api_key = "votre-clé-api"
|
||||
temperature = 0.8
|
||||
top_p = 0.9
|
||||
```
|
||||
|
||||
Chaque configuration nommée hérite de tous les paramètres de la section `[llm]` par défaut et peut remplacer n'importe lequel de ces paramètres. Vous pouvez définir autant de configurations personnalisées que nécessaire.
|
||||
|
||||
## Utilisation des configurations personnalisées
|
||||
|
||||
### Avec les agents
|
||||
|
||||
Vous pouvez spécifier quelle configuration LLM un agent doit utiliser en définissant le paramètre `llm_config` dans la section de configuration de l'agent :
|
||||
|
||||
```toml
|
||||
[agent.RepoExplorerAgent]
|
||||
# Utiliser la configuration GPT-3 moins coûteuse pour cet agent
|
||||
llm_config = 'gpt3'
|
||||
|
||||
[agent.CodeWriterAgent]
|
||||
# Utiliser la configuration haute créativité pour cet agent
|
||||
llm_config = 'haute-creativite'
|
||||
```
|
||||
|
||||
### Options de configuration
|
||||
|
||||
Chaque configuration LLM nommée prend en charge toutes les mêmes options que la configuration LLM par défaut. Celles-ci incluent :
|
||||
|
||||
- Sélection du modèle (`model`)
|
||||
- Configuration de l'API (`api_key`, `base_url`, etc.)
|
||||
- Paramètres du modèle (`temperature`, `top_p`, etc.)
|
||||
- Paramètres de nouvelle tentative (`num_retries`, `retry_multiplier`, etc.)
|
||||
- Limites de jetons (`max_input_tokens`, `max_output_tokens`)
|
||||
- Et toutes les autres options de configuration LLM
|
||||
|
||||
Pour une liste complète des options disponibles, consultez la section Configuration LLM dans la documentation des [Options de configuration](../configuration-options).
|
||||
|
||||
## Cas d'utilisation
|
||||
|
||||
Les configurations LLM personnalisées sont particulièrement utiles dans plusieurs scénarios :
|
||||
|
||||
- **Optimisation des coûts** : Utiliser des modèles moins coûteux pour les tâches qui ne nécessitent pas de réponses de haute qualité, comme l'exploration de dépôt ou les opérations simples sur les fichiers.
|
||||
- **Réglage spécifique aux tâches** : Configurer différentes valeurs de température et de top_p pour les tâches qui nécessitent différents niveaux de créativité ou de déterminisme.
|
||||
- **Différents fournisseurs** : Utiliser différents fournisseurs LLM ou points d'accès API pour différentes tâches.
|
||||
- **Tests et développement** : Basculer facilement entre différentes configurations de modèles pendant le développement et les tests.
|
||||
|
||||
## Exemple : Optimisation des coûts
|
||||
|
||||
Un exemple pratique d'utilisation des configurations LLM personnalisées pour optimiser les coûts :
|
||||
|
||||
```toml
|
||||
# Configuration par défaut utilisant GPT-4 pour des réponses de haute qualité
|
||||
[llm]
|
||||
model = "gpt-4"
|
||||
api_key = "votre-clé-api"
|
||||
temperature = 0.0
|
||||
|
||||
# Configuration moins coûteuse pour l'exploration de dépôt
|
||||
[llm.repo-explorer]
|
||||
model = "gpt-3.5-turbo"
|
||||
temperature = 0.2
|
||||
|
||||
# Configuration pour la génération de code
|
||||
[llm.code-gen]
|
||||
model = "gpt-4"
|
||||
temperature = 0.0
|
||||
max_output_tokens = 2000
|
||||
|
||||
[agent.RepoExplorerAgent]
|
||||
llm_config = 'repo-explorer'
|
||||
|
||||
[agent.CodeWriterAgent]
|
||||
llm_config = 'code-gen'
|
||||
```
|
||||
|
||||
Dans cet exemple :
|
||||
- L'exploration de dépôt utilise un modèle moins coûteux car il s'agit principalement de comprendre et de naviguer dans le code
|
||||
- La génération de code utilise GPT-4 avec une limite de jetons plus élevée pour générer des blocs de code plus importants
|
||||
- La configuration par défaut reste disponible pour les autres tâches
|
||||
|
||||
:::note
|
||||
Les configurations LLM personnalisées ne sont disponibles que lors de l'utilisation d'OpenHands en mode développement, via `main.py` ou `cli.py`. Lors de l'exécution via `docker run`, veuillez utiliser les options de configuration standard.
|
||||
:::
|
||||
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -373,7 +373,7 @@ Agent 配置选项在 `config.toml` 文件的 `[agent]` 和 `[agent.<agent_name>
|
||||
- 描述: 是否在 action space 中启用 Jupyter
|
||||
|
||||
**Microagent 使用**
|
||||
- `use_microagents`
|
||||
- `enable_prompt_extensions`
|
||||
- 类型: `bool`
|
||||
- 默认值: `true`
|
||||
- 描述: 是否使用 microagents
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -57,6 +57,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -55,6 +55,11 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `"./trajectories"`
|
||||
- Description: Path to store trajectories (can be a folder or a file). If it's a folder, the trajectories will be saved in a file named with the session id name and .json extension, in that folder.
|
||||
|
||||
- `replay_trajectory_path`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: Path to load a trajectory and replay. If given, must be a path to the trajectory file in JSON format. The actions in the trajectory file would be replayed first before any user instruction is executed.
|
||||
|
||||
### File Store
|
||||
- `file_store_path`
|
||||
- Type: `str`
|
||||
@@ -140,7 +145,11 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
|
||||
|
||||
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
|
||||
|
||||
### AWS Credentials
|
||||
:::note
|
||||
For development setups, you can also define custom named LLM configurations. See [Custom LLM Configurations](./llms/custom-llm-configs) for details.
|
||||
:::
|
||||
|
||||
**AWS Credentials**
|
||||
- `aws_access_key_id`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -332,7 +341,7 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Description: Whether Jupyter is enabled in the action space
|
||||
|
||||
### Microagent Usage
|
||||
- `use_microagents`
|
||||
- `enable_prompt_extensions`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
- Description: Whether to use microagents at all
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Getting Started with OpenHands
|
||||
|
||||
So you've [installed OpenHands](./installation) and have
|
||||
So you've [run OpenHands](./installation) and have
|
||||
[set up your LLM](./installation#setup). Now what?
|
||||
|
||||
OpenHands can help you tackle a wide variety of engineering tasks. But the technology
|
||||
|
||||
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -45,7 +45,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -18,15 +18,21 @@ If you choose the first option, you can skip the `Create Your Docker Image` sect
|
||||
|
||||
To create a custom Docker image, it must be Debian based.
|
||||
|
||||
For example, if you want OpenHands to have `ruby` installed, create a `Dockerfile` with the following content:
|
||||
For example, if you want OpenHands to have `ruby` installed, you could create a `Dockerfile` with the following content:
|
||||
|
||||
```dockerfile
|
||||
FROM debian:latest
|
||||
FROM nikolaik/python-nodejs:python3.12-nodejs22
|
||||
|
||||
# Install required packages
|
||||
RUN apt-get update && apt-get install -y ruby
|
||||
```
|
||||
|
||||
Or you could use a Ruby-specific base image:
|
||||
|
||||
```dockerfile
|
||||
FROM ruby:latest
|
||||
```
|
||||
|
||||
Save this file in a folder. Then, build your Docker image (e.g., named custom-image) by navigating to the folder in
|
||||
the terminal and running::
|
||||
```bash
|
||||
@@ -55,6 +61,28 @@ This can be an image you’ve already pulled or one you’ve built:
|
||||
sandbox_base_container_image="custom-image"
|
||||
```
|
||||
|
||||
### Additional Configuration Options
|
||||
|
||||
The `config.toml` file supports several other options for customizing your sandbox:
|
||||
|
||||
```toml
|
||||
[core]
|
||||
# Install additional dependencies when the runtime is built
|
||||
# Can contain any valid shell commands
|
||||
# If you need the path to the Python interpreter in any of these commands, you can use the $OH_INTERPRETER_PATH variable
|
||||
runtime_extra_deps = """
|
||||
pip install numpy pandas
|
||||
apt-get update && apt-get install -y ffmpeg
|
||||
"""
|
||||
|
||||
# Set environment variables for the runtime
|
||||
# Useful for configuration that needs to be available at runtime
|
||||
runtime_startup_env_vars = { DATABASE_URL = "postgresql://user:pass@localhost/db" }
|
||||
|
||||
# Specify platform for multi-architecture builds (e.g., "linux/amd64" or "linux/arm64")
|
||||
platform = "linux/amd64"
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
Run OpenHands by running ```make run``` in the top level directory.
|
||||
|
||||
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -43,7 +43,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,27 +1,66 @@
|
||||
# Installation
|
||||
# Running OpenHands
|
||||
|
||||
## System Requirements
|
||||
|
||||
- Docker version 26.0.0+ or Docker Desktop 4.31.0+.
|
||||
- You must be using Linux or Mac OS.
|
||||
- If you are on Windows, you must use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
|
||||
- Linux
|
||||
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
|
||||
|
||||
## Start the app
|
||||
## Prerequisites
|
||||
|
||||
<details>
|
||||
<summary>MacOS</summary>
|
||||
### Docker Desktop
|
||||
|
||||
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
|
||||
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Linux</summary>
|
||||
|
||||
:::note
|
||||
Tested with Ubuntu 22.04.
|
||||
:::
|
||||
|
||||
### Docker Desktop
|
||||
|
||||
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Windows</summary>
|
||||
### WSL
|
||||
|
||||
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
|
||||
|
||||
### Docker Desktop
|
||||
|
||||
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
|
||||
2. Open Docker Desktop, go to `Settings` and confirm the following:
|
||||
- General: `Use the WSL 2 based engine` is enabled.
|
||||
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
|
||||
|
||||
</details>
|
||||
|
||||
## Start the App
|
||||
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
136
docs/modules/usage/llms/custom-llm-configs.md
Normal file
136
docs/modules/usage/llms/custom-llm-configs.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Custom LLM Configurations
|
||||
|
||||
OpenHands supports defining multiple named LLM configurations in your `config.toml` file. This feature allows you to use different LLM configurations for different purposes, such as using a cheaper model for tasks that don't require high-quality responses, or using different models with different parameters for specific agents.
|
||||
|
||||
## How It Works
|
||||
|
||||
Named LLM configurations are defined in the `config.toml` file using sections that start with `llm.`. For example:
|
||||
|
||||
```toml
|
||||
# Default LLM configuration
|
||||
[llm]
|
||||
model = "gpt-4"
|
||||
api_key = "your-api-key"
|
||||
temperature = 0.0
|
||||
|
||||
# Custom LLM configuration for a cheaper model
|
||||
[llm.gpt3]
|
||||
model = "gpt-3.5-turbo"
|
||||
api_key = "your-api-key"
|
||||
temperature = 0.2
|
||||
|
||||
# Another custom configuration with different parameters
|
||||
[llm.high-creativity]
|
||||
model = "gpt-4"
|
||||
api_key = "your-api-key"
|
||||
temperature = 0.8
|
||||
top_p = 0.9
|
||||
```
|
||||
|
||||
Each named configuration inherits all settings from the default `[llm]` section and can override any of those settings. You can define as many custom configurations as needed.
|
||||
|
||||
## Using Custom Configurations
|
||||
|
||||
### With Agents
|
||||
|
||||
You can specify which LLM configuration an agent should use by setting the `llm_config` parameter in the agent's configuration section:
|
||||
|
||||
```toml
|
||||
[agent.RepoExplorerAgent]
|
||||
# Use the cheaper GPT-3 configuration for this agent
|
||||
llm_config = 'gpt3'
|
||||
|
||||
[agent.CodeWriterAgent]
|
||||
# Use the high creativity configuration for this agent
|
||||
llm_config = 'high-creativity'
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
Each named LLM configuration supports all the same options as the default LLM configuration. These include:
|
||||
|
||||
- Model selection (`model`)
|
||||
- API configuration (`api_key`, `base_url`, etc.)
|
||||
- Model parameters (`temperature`, `top_p`, etc.)
|
||||
- Retry settings (`num_retries`, `retry_multiplier`, etc.)
|
||||
- Token limits (`max_input_tokens`, `max_output_tokens`)
|
||||
- And all other LLM configuration options
|
||||
|
||||
For a complete list of available options, see the LLM Configuration section in the [Configuration Options](../configuration-options) documentation.
|
||||
|
||||
## Use Cases
|
||||
|
||||
Custom LLM configurations are particularly useful in several scenarios:
|
||||
|
||||
- **Cost Optimization**: Use cheaper models for tasks that don't require high-quality responses, like repository exploration or simple file operations.
|
||||
- **Task-Specific Tuning**: Configure different temperature and top_p values for tasks that require different levels of creativity or determinism.
|
||||
- **Different Providers**: Use different LLM providers or API endpoints for different tasks.
|
||||
- **Testing and Development**: Easily switch between different model configurations during development and testing.
|
||||
|
||||
## Example: Cost Optimization
|
||||
|
||||
A practical example of using custom LLM configurations to optimize costs:
|
||||
|
||||
```toml
|
||||
# Default configuration using GPT-4 for high-quality responses
|
||||
[llm]
|
||||
model = "gpt-4"
|
||||
api_key = "your-api-key"
|
||||
temperature = 0.0
|
||||
|
||||
# Cheaper configuration for repository exploration
|
||||
[llm.repo-explorer]
|
||||
model = "gpt-3.5-turbo"
|
||||
temperature = 0.2
|
||||
|
||||
# Configuration for code generation
|
||||
[llm.code-gen]
|
||||
model = "gpt-4"
|
||||
temperature = 0.0
|
||||
max_output_tokens = 2000
|
||||
|
||||
[agent.RepoExplorerAgent]
|
||||
llm_config = 'repo-explorer'
|
||||
|
||||
[agent.CodeWriterAgent]
|
||||
llm_config = 'code-gen'
|
||||
```
|
||||
|
||||
In this example:
|
||||
- Repository exploration uses a cheaper model since it mainly involves understanding and navigating code
|
||||
- Code generation uses GPT-4 with a higher token limit for generating larger code blocks
|
||||
- The default configuration remains available for other tasks
|
||||
|
||||
# Custom Configurations with Reserved Names
|
||||
|
||||
OpenHands can use custom LLM configurations named with reserved names, for specific use cases. If you specify the model and other settings under the reserved names, then OpenHands will load and them for a specific purpose. As of now, one such configuration is implemented: draft editor.
|
||||
|
||||
## Draft Editor Configuration
|
||||
|
||||
The `draft_editor` configuration is a group of settings you can provide, to specify the model to use for preliminary drafting of code edits, for any tasks that involve editing and refining code. You need to provide it under the section `[llm.draft_editor]`.
|
||||
|
||||
For example, you can define in `config.toml` a draft editor like this:
|
||||
|
||||
```toml
|
||||
[llm.draft_editor]
|
||||
model = "gpt-4"
|
||||
temperature = 0.2
|
||||
top_p = 0.95
|
||||
presence_penalty = 0.0
|
||||
frequency_penalty = 0.0
|
||||
```
|
||||
|
||||
This configuration:
|
||||
- Uses GPT-4 for high-quality edits and suggestions
|
||||
- Sets a low temperature (0.2) to maintain consistency while allowing some flexibility
|
||||
- Uses a high top_p value (0.95) to consider a wide range of token options
|
||||
- Disables presence and frequency penalties to maintain focus on the specific edits needed
|
||||
|
||||
Use this configuration when you want to let an LLM draft edits before making them. In general, it may be useful to:
|
||||
- Review and suggest code improvements
|
||||
- Refine existing content while maintaining its core meaning
|
||||
- Make precise, focused changes to code or text
|
||||
|
||||
:::note
|
||||
Custom LLM configurations are only available when using OpenHands in development mode, via `main.py` or `cli.py`. When running via `docker run`, please use the standard configuration options.
|
||||
:::
|
||||
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -5,7 +5,7 @@ const sidebars: SidebarsConfig = {
|
||||
docsSidebar: [
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Installation',
|
||||
label: 'Running OpenHands',
|
||||
id: 'usage/installation',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -76,7 +76,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
|
||||
# copy 'draft_editor' config if exists
|
||||
config_copy = copy.deepcopy(config)
|
||||
|
||||
@@ -74,7 +74,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ def initialize_runtime(
|
||||
action = CmdRunAction(
|
||||
command=f'git clone -b commit0_combined https://github.com/{instance["repo"]}.git'
|
||||
)
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -181,7 +181,7 @@ def initialize_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -191,7 +191,7 @@ def initialize_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command='git checkout -b openhands')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -201,7 +201,7 @@ def initialize_runtime(
|
||||
|
||||
# Install commit0
|
||||
action = CmdRunAction(command='/root/.cargo/bin/uv pip install commit0')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
# logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -231,7 +231,7 @@ def complete_runtime(
|
||||
workspace_dir_name = _get_commit0_workspace_dir_name(instance)
|
||||
|
||||
action = CmdRunAction(command='git add .')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -241,7 +241,7 @@ def complete_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command='git commit -m "openhands edits"')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -258,7 +258,7 @@ def complete_runtime(
|
||||
action = CmdRunAction(
|
||||
command=f"git diff {instance['base_commit']} HEAD -- . ':(exclude)spec.pdf.bz2'"
|
||||
)
|
||||
action.timeout = 600 + 100 * n_retries
|
||||
action.set_hard_timeout(600 + 100 * n_retries)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
# logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -282,7 +282,7 @@ def complete_runtime(
|
||||
action = CmdRunAction(
|
||||
command=f"{instance['test']['test_cmd']} --json-report --json-report-file=report.json --continue-on-collection-errors {test_dir} > test_output.txt 2>&1"
|
||||
)
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -292,7 +292,7 @@ def complete_runtime(
|
||||
)
|
||||
# Read test output
|
||||
action = CmdRunAction(command='cat test_output.txt')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
# logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -305,7 +305,7 @@ def complete_runtime(
|
||||
|
||||
# Save pytest exit code
|
||||
action = CmdRunAction(command='echo $?')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
# logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -318,7 +318,7 @@ def complete_runtime(
|
||||
|
||||
# Read the test report
|
||||
action = CmdRunAction(command='cat report.json')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
# logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -330,7 +330,7 @@ def complete_runtime(
|
||||
repo_name = instance['repo'].split('/')[1]
|
||||
repo_name = repo_name.replace('.', '-')
|
||||
action = CmdRunAction(command=f'commit0 get-tests {repo_name}')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
# logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
@@ -78,7 +78,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
agent_config = AgentConfig(
|
||||
function_calling=False,
|
||||
codeact_enable_jupyter=True,
|
||||
|
||||
@@ -63,7 +63,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
|
||||
|
||||
## Test if your environment works
|
||||
|
||||
Follow the instructions here https://miniwob.farama.org/content/getting_started/ & https://miniwob.farama.org/content/viewing/
|
||||
to set up MiniWoB server in your local environment at http://localhost:8080/miniwob/
|
||||
|
||||
Access with browser the above MiniWoB URLs and see if they load correctly.
|
||||
|
||||
## Run Evaluation
|
||||
|
||||
@@ -120,7 +120,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
@@ -11,7 +12,11 @@ from swebench.harness.run_evaluation import (
|
||||
)
|
||||
from swebench.harness.test_spec import SWEbenchInstance, TestSpec, make_test_spec
|
||||
from swebench.harness.utils import load_swebench_dataset
|
||||
from tqdm import tqdm
|
||||
|
||||
from evaluation.benchmarks.swe_bench.resource.mapping import (
|
||||
get_instance_resource_factor,
|
||||
)
|
||||
from evaluation.benchmarks.swe_bench.run_infer import get_instance_docker_image
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
@@ -66,7 +71,7 @@ def process_git_patch(patch):
|
||||
return patch
|
||||
|
||||
|
||||
def get_config(instance: pd.Series) -> AppConfig:
|
||||
def get_config(metadata: EvalMetadata, instance: pd.Series) -> AppConfig:
|
||||
# We use a different instance image for the each instance of swe-bench eval
|
||||
base_container_image = get_instance_docker_image(instance['instance_id'])
|
||||
logger.info(
|
||||
@@ -81,10 +86,14 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
base_container_image=base_container_image,
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=1800,
|
||||
timeout=600,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_resource_factor=get_instance_resource_factor(
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
),
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -123,7 +132,7 @@ def process_instance(
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
config = get_config(instance)
|
||||
config = get_config(metadata, instance)
|
||||
instance_id = instance.instance_id
|
||||
model_patch = instance['model_patch']
|
||||
test_spec: TestSpec = instance['test_spec']
|
||||
@@ -151,52 +160,52 @@ def process_instance(
|
||||
if runtime_failure_count > 0:
|
||||
config.sandbox.remote_runtime_resource_factor = min(
|
||||
config.sandbox.remote_runtime_resource_factor * (2**runtime_failure_count),
|
||||
4, # hardcode maximum resource factor to 4
|
||||
8,
|
||||
)
|
||||
logger.warning(
|
||||
f'This is the second attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
f'This is the {runtime_failure_count + 1}th attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
)
|
||||
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
# Get patch and save it to /tmp/patch.diff
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Patch file
|
||||
patch_file_path = os.path.join(temp_dir, 'patch.diff')
|
||||
with open(patch_file_path, 'w') as f:
|
||||
f.write(model_patch)
|
||||
runtime.copy_to(patch_file_path, '/tmp')
|
||||
# Eval script
|
||||
eval_script_path = os.path.join(temp_dir, 'eval.sh')
|
||||
with open(eval_script_path, 'w') as f:
|
||||
f.write(test_spec.eval_script)
|
||||
runtime.copy_to(eval_script_path, '/tmp')
|
||||
|
||||
# Set +x
|
||||
action = CmdRunAction(command='chmod +x /tmp/eval.sh')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.exit_code == 0
|
||||
|
||||
# Apply patch
|
||||
exec_command = (
|
||||
'cd /testbed && '
|
||||
"(git apply -v /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
|
||||
"(echo 'Failed to apply patch with git apply, trying with patch command...' && "
|
||||
"(patch --batch --fuzz=5 -p1 -i /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
|
||||
"echo 'APPLY_PATCH_FAIL')))"
|
||||
)
|
||||
action = CmdRunAction(command=exec_command)
|
||||
action.timeout = 600
|
||||
obs = runtime.run_action(action)
|
||||
assert isinstance(obs, CmdOutputObservation)
|
||||
apply_patch_output = obs.content
|
||||
assert isinstance(apply_patch_output, str)
|
||||
instance['test_result']['apply_patch_output'] = apply_patch_output
|
||||
|
||||
try:
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
# Get patch and save it to /tmp/patch.diff
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Patch file
|
||||
patch_file_path = os.path.join(temp_dir, 'patch.diff')
|
||||
with open(patch_file_path, 'w') as f:
|
||||
f.write(model_patch)
|
||||
runtime.copy_to(patch_file_path, '/tmp')
|
||||
# Eval script
|
||||
eval_script_path = os.path.join(temp_dir, 'eval.sh')
|
||||
with open(eval_script_path, 'w') as f:
|
||||
f.write(test_spec.eval_script)
|
||||
runtime.copy_to(eval_script_path, '/tmp')
|
||||
|
||||
# Set +x
|
||||
action = CmdRunAction(command='chmod +x /tmp/eval.sh')
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.exit_code == 0
|
||||
|
||||
# Apply patch
|
||||
exec_command = (
|
||||
'cd /testbed && '
|
||||
"(git apply -v /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
|
||||
"(echo 'Failed to apply patch with git apply, trying with patch command...' && "
|
||||
"(patch --batch --fuzz=5 -p1 -i /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
|
||||
"echo 'APPLY_PATCH_FAIL')))"
|
||||
)
|
||||
action = CmdRunAction(command=exec_command)
|
||||
action.set_hard_timeout(600)
|
||||
obs = runtime.run_action(action)
|
||||
assert isinstance(obs, CmdOutputObservation)
|
||||
apply_patch_output = obs.content
|
||||
assert isinstance(apply_patch_output, str)
|
||||
instance['test_result']['apply_patch_output'] = apply_patch_output
|
||||
|
||||
if 'APPLY_PATCH_FAIL' in apply_patch_output:
|
||||
logger.info(f'[{instance_id}] {APPLY_PATCH_FAIL}:\n{apply_patch_output}')
|
||||
instance['test_result']['report']['failed_apply_patch'] = True
|
||||
@@ -212,7 +221,7 @@ def process_instance(
|
||||
# Run eval script in background and save output to log file
|
||||
log_file = '/tmp/eval_output.log'
|
||||
action = CmdRunAction(command=f'/tmp/eval.sh > {log_file} 2>&1 & echo $!')
|
||||
action.timeout = 60 # Short timeout just to get the process ID
|
||||
action.set_hard_timeout(300) # Short timeout just to get the process ID
|
||||
obs = runtime.run_action(action)
|
||||
|
||||
if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
|
||||
@@ -235,7 +244,7 @@ def process_instance(
|
||||
check_action = CmdRunAction(
|
||||
command=f'ps -p {pid} > /dev/null; echo $?'
|
||||
)
|
||||
check_action.timeout = 60
|
||||
check_action.set_hard_timeout(300)
|
||||
check_obs = runtime.run_action(check_action)
|
||||
if (
|
||||
isinstance(check_obs, CmdOutputObservation)
|
||||
@@ -252,7 +261,7 @@ def process_instance(
|
||||
|
||||
# Read the log file
|
||||
cat_action = CmdRunAction(command=f'cat {log_file}')
|
||||
cat_action.timeout = 300
|
||||
cat_action.set_hard_timeout(300)
|
||||
cat_obs = runtime.run_action(cat_action)
|
||||
|
||||
# Grade answer
|
||||
@@ -352,7 +361,14 @@ if __name__ == '__main__':
|
||||
|
||||
# Load predictions
|
||||
assert args.input_file.endswith('.jsonl'), 'Input file must be a jsonl file.'
|
||||
predictions = pd.read_json(args.input_file, lines=True)
|
||||
required_fields = ['instance_id', 'model_patch', 'test_result']
|
||||
with open(args.input_file) as f:
|
||||
predictions = pd.DataFrame.from_records(
|
||||
[
|
||||
{k: v for k, v in json.loads(line).items() if k in required_fields}
|
||||
for line in tqdm(f, desc='Loading predictions')
|
||||
]
|
||||
)
|
||||
assert (
|
||||
'instance_id' in predictions.columns
|
||||
), 'Input file must contain instance_id column.'
|
||||
|
||||
38
evaluation/benchmarks/swe_bench/resource/mapping.py
Normal file
38
evaluation/benchmarks/swe_bench/resource/mapping.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Mapping instance_id to resource_factor.
|
||||
|
||||
Different instances may have different resource requirements.
|
||||
e.g., some instances may require more memory/CPU to run inference.
|
||||
This file tracks the resource requirements of different instances.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_RUNTIME_RESOURCE_FACTOR = int(
|
||||
os.environ.get('DEFAULT_RUNTIME_RESOURCE_FACTOR', 1)
|
||||
)
|
||||
|
||||
# dataset to resource mapping
|
||||
_global_resource_mapping: dict[str, dict[str, float]] = {}
|
||||
|
||||
|
||||
def get_resource_mapping(dataset_name: str) -> dict[str, float]:
|
||||
if dataset_name not in _global_resource_mapping:
|
||||
file_path = os.path.join(CUR_DIR, f'{dataset_name}.json')
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f'Resource mapping for {dataset_name} not found.')
|
||||
return None
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
_global_resource_mapping[dataset_name] = json.load(f)
|
||||
logger.info(f'Loaded resource mapping for {dataset_name}')
|
||||
return _global_resource_mapping[dataset_name]
|
||||
|
||||
|
||||
def get_instance_resource_factor(dataset_name: str, instance_id: str) -> int:
|
||||
resource_mapping = get_resource_mapping(dataset_name)
|
||||
if resource_mapping is None:
|
||||
return DEFAULT_RUNTIME_RESOURCE_FACTOR
|
||||
return int(resource_mapping.get(instance_id, DEFAULT_RUNTIME_RESOURCE_FACTOR))
|
||||
@@ -0,0 +1 @@
|
||||
{"pydata__xarray-6721": 8, "pytest-dev__pytest-7236": 8, "matplotlib__matplotlib-24627": 4, "django__django-15561": 4, "django__django-15098": 4, "django__django-14771": 4, "sympy__sympy-21612": 4, "sympy__sympy-15345": 4, "psf__requests-5414": 4, "astropy__astropy-14508": 2, "django__django-11451": 2, "django__django-11477": 2, "django__django-10880": 2, "django__django-11163": 2, "django__django-11815": 2, "astropy__astropy-14369": 2, "django__django-10097": 2, "django__django-10554": 2, "django__django-12304": 2, "django__django-12325": 2, "django__django-11551": 2, "django__django-11734": 2, "django__django-13109": 2, "django__django-13089": 2, "django__django-13343": 2, "django__django-13363": 2, "django__django-13809": 2, "django__django-13810": 2, "django__django-13786": 2, "django__django-13807": 2, "django__django-14493": 2, "django__django-11820": 2, "django__django-11951": 2, "django__django-11964": 2, "astropy__astropy-14309": 2, "astropy__astropy-14365": 2, "astropy__astropy-12907": 2, "astropy__astropy-14182": 2, "django__django-15161": 2, "django__django-15128": 2, "django__django-14999": 2, "django__django-14915": 2, "django__django-14752": 2, "django__django-14765": 2, "django__django-14089": 2, "django__django-15252": 2, "django__django-15380": 2, "django__django-15382": 2, "django__django-15499": 2, "django__django-15467": 2, "django__django-15280": 2, "django__django-15315": 2, "django__django-15277": 2, "django__django-15268": 2, "django__django-15629": 2, "django__django-15695": 2, "django__django-15732": 2, "django__django-15863": 2, "django__django-16082": 2, "django__django-16145": 2, "django__django-16256": 2, "django__django-16429": 2, "django__django-16454": 2, "django__django-16493": 2, "matplotlib__matplotlib-13989": 2, "matplotlib__matplotlib-20488": 2, "django__django-15503": 2, "django__django-15525": 2, "django__django-15375": 2, "django__django-15278": 2, "matplotlib__matplotlib-21568": 2, "matplotlib__matplotlib-20859": 2, "matplotlib__matplotlib-20826": 2, "matplotlib__matplotlib-20676": 2, "matplotlib__matplotlib-23412": 2, "matplotlib__matplotlib-22719": 2, "matplotlib__matplotlib-23299": 2, "matplotlib__matplotlib-22865": 2, "matplotlib__matplotlib-24149": 2, "matplotlib__matplotlib-24177": 2, "matplotlib__matplotlib-24570": 2, "matplotlib__matplotlib-24637": 2, "matplotlib__matplotlib-24970": 2, "matplotlib__matplotlib-23476": 2, "matplotlib__matplotlib-24026": 2, "matplotlib__matplotlib-23314": 2, "matplotlib__matplotlib-25332": 2, "matplotlib__matplotlib-25311": 2, "matplotlib__matplotlib-25122": 2, "matplotlib__matplotlib-25479": 2, "matplotlib__matplotlib-26342": 2, "psf__requests-2317": 2, "matplotlib__matplotlib-25960": 2, "matplotlib__matplotlib-25775": 2, "pydata__xarray-4356": 2, "pydata__xarray-4075": 2, "pydata__xarray-6461": 2, "pydata__xarray-4687": 2, "pydata__xarray-6599": 2, "pylint-dev__pylint-4661": 2, "django__django-15554": 2, "django__django-15563": 2, "pytest-dev__pytest-5262": 2, "pytest-dev__pytest-10081": 2, "scikit-learn__scikit-learn-12973": 2, "scikit-learn__scikit-learn-13124": 2, "scikit-learn__scikit-learn-13779": 2, "scikit-learn__scikit-learn-14141": 2, "scikit-learn__scikit-learn-13439": 2, "scikit-learn__scikit-learn-13496": 2, "scikit-learn__scikit-learn-15100": 2, "scikit-learn__scikit-learn-25102": 2, "scikit-learn__scikit-learn-25232": 2, "scikit-learn__scikit-learn-25747": 2, "scikit-learn__scikit-learn-26323": 2, "scikit-learn__scikit-learn-9288": 2, "scikit-learn__scikit-learn-14496": 2, "scikit-learn__scikit-learn-14629": 2, "sphinx-doc__sphinx-8265": 2, "sphinx-doc__sphinx-8548": 2, "sphinx-doc__sphinx-8593": 2, "sphinx-doc__sphinx-8595": 2, "sphinx-doc__sphinx-8621": 2, "sphinx-doc__sphinx-8638": 2, "sphinx-doc__sphinx-9229": 2, "sphinx-doc__sphinx-9281": 2, "sphinx-doc__sphinx-9461": 2, "sphinx-doc__sphinx-9591": 2, "sphinx-doc__sphinx-9658": 2, "sphinx-doc__sphinx-9673": 2, "sympy__sympy-12096": 2, "sympy__sympy-12481": 2, "sphinx-doc__sphinx-10323": 2, "sphinx-doc__sphinx-7590": 2, "sympy__sympy-13877": 2, "sympy__sympy-12489": 2, "sympy__sympy-15809": 2, "sympy__sympy-14711": 2, "sympy__sympy-16597": 2, "sympy__sympy-16766": 2, "sympy__sympy-16792": 2, "sympy__sympy-15875": 2, "sympy__sympy-17655": 2, "sympy__sympy-18189": 2, "sympy__sympy-18763": 2, "sympy__sympy-19040": 2, "sympy__sympy-19495": 2, "sympy__sympy-19637": 2, "sympy__sympy-19783": 2, "sympy__sympy-17630": 2, "sympy__sympy-20428": 2, "sympy__sympy-20590": 2, "sympy__sympy-20801": 2, "sympy__sympy-21379": 2, "sympy__sympy-21847": 2, "sympy__sympy-22456": 2, "sympy__sympy-22714": 2, "sympy__sympy-22914": 2, "sympy__sympy-23262": 2, "sympy__sympy-23413": 2, "sympy__sympy-23534": 2, "sympy__sympy-24066": 2, "sympy__sympy-24213": 2, "sympy__sympy-24443": 2, "sympy__sympy-24562": 2, "sympy__sympy-24661": 2}
|
||||
@@ -9,6 +9,9 @@ import toml
|
||||
from datasets import load_dataset
|
||||
|
||||
import openhands.agenthub
|
||||
from evaluation.benchmarks.swe_bench.resource.mapping import (
|
||||
get_instance_resource_factor,
|
||||
)
|
||||
from evaluation.utils.shared import (
|
||||
EvalException,
|
||||
EvalMetadata,
|
||||
@@ -41,9 +44,10 @@ from openhands.utils.async_utils import call_async_from_sync
|
||||
from openhands.utils.shutdown_listener import sleep_if_should_continue
|
||||
|
||||
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
|
||||
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'false').lower() == 'true'
|
||||
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'true').lower() == 'true'
|
||||
RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'true'
|
||||
|
||||
|
||||
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
|
||||
'CodeActAgent': codeact_user_response,
|
||||
}
|
||||
@@ -135,6 +139,10 @@ def get_config(
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_resource_factor=get_instance_resource_factor(
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
),
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -150,6 +158,7 @@ def get_config(
|
||||
codeact_enable_browsing=RUN_WITH_BROWSING,
|
||||
codeact_enable_llm_editor=False,
|
||||
condenser=metadata.condenser_config,
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
@@ -173,7 +182,7 @@ def initialize_runtime(
|
||||
action = CmdRunAction(
|
||||
command=f"""echo 'export SWE_INSTANCE_ID={instance['instance_id']}' >> ~/.bashrc && echo 'export PIP_CACHE_DIR=~/.cache/pip' >> ~/.bashrc && echo "alias git='git --no-pager'" >> ~/.bashrc"""
|
||||
)
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -182,7 +191,7 @@ def initialize_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command="""export USER=$(whoami); echo USER=${USER} """)
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -194,7 +203,7 @@ def initialize_runtime(
|
||||
|
||||
# inject the instance info
|
||||
action = CmdRunAction(command='mkdir -p /swe_util/eval_data/instances')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -223,14 +232,14 @@ def initialize_runtime(
|
||||
'/swe_util/',
|
||||
)
|
||||
action = CmdRunAction(command='cat ~/.bashrc')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to cat ~/.bashrc: {str(obs)}')
|
||||
|
||||
action = CmdRunAction(command='source ~/.bashrc')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -239,7 +248,7 @@ def initialize_runtime(
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to source ~/.bashrc: {str(obs)}')
|
||||
|
||||
action = CmdRunAction(command='source /swe_util/instance_swe_entry.sh')
|
||||
action.timeout = 3600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -249,7 +258,7 @@ def initialize_runtime(
|
||||
)
|
||||
else:
|
||||
action = CmdRunAction(command='source /swe_util/swe_entry.sh')
|
||||
action.timeout = 1800
|
||||
action.set_hard_timeout(1800)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -259,7 +268,7 @@ def initialize_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -269,7 +278,7 @@ def initialize_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command='git reset --hard')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -278,14 +287,14 @@ def initialize_runtime(
|
||||
action = CmdRunAction(
|
||||
command='for remote_name in $(git remote); do git remote remove "${remote_name}"; done'
|
||||
)
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to remove git remotes: {str(obs)}')
|
||||
|
||||
action = CmdRunAction(command='which python')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -316,7 +325,7 @@ def complete_runtime(
|
||||
workspace_dir_name = _get_swebench_workspace_dir_name(instance)
|
||||
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -326,7 +335,7 @@ def complete_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command='git config --global core.pager ""')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -336,7 +345,7 @@ def complete_runtime(
|
||||
)
|
||||
|
||||
action = CmdRunAction(command='git add -A')
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -351,7 +360,7 @@ def complete_runtime(
|
||||
action = CmdRunAction(
|
||||
command=f'git diff --no-color --cached {instance["base_commit"]}'
|
||||
)
|
||||
action.timeout = 600 + 100 * n_retries
|
||||
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -399,7 +408,7 @@ def process_instance(
|
||||
8,
|
||||
)
|
||||
logger.warning(
|
||||
f'This is the second attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
f'This is the {runtime_failure_count + 1}th attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
)
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
@@ -479,6 +488,10 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
subset = dataset[dataset[filter_column].isin(selected_ids)]
|
||||
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
|
||||
return subset
|
||||
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
|
||||
if len(skip_ids) > 0:
|
||||
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
|
||||
return dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
return dataset
|
||||
|
||||
|
||||
@@ -501,8 +514,10 @@ if __name__ == '__main__':
|
||||
# NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
|
||||
# so we don't need to manage file uploading to OpenHands's repo
|
||||
dataset = load_dataset(args.dataset, split=args.split)
|
||||
logger.info(f'Loaded dataset {args.dataset} with split {args.split}')
|
||||
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
@@ -531,6 +546,7 @@ if __name__ == '__main__':
|
||||
)
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
print(f'### OUTPUT FILE: {output_file} ###')
|
||||
instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit)
|
||||
|
||||
if len(instances) > 0 and not isinstance(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
tqdm.pandas()
|
||||
|
||||
|
||||
# Load trajectories for resolved instances
|
||||
def load_completions(output_dir: str, instance_id: str):
|
||||
glob_path = os.path.join(output_dir, 'llm_completions', instance_id, '*.json')
|
||||
files = sorted(glob(glob_path)) # this is ascending order
|
||||
# pick the last file (last turn)
|
||||
try:
|
||||
file_path = files[-1]
|
||||
except IndexError:
|
||||
# print(f'No files found for instance {instance_id}: files={files}')
|
||||
return None
|
||||
with open(file_path, 'r') as f:
|
||||
result = json.load(f)
|
||||
# create messages
|
||||
messages = result['messages']
|
||||
messages.append(result['response']['choices'][0]['message'])
|
||||
tools = result['kwargs']['tools']
|
||||
return {
|
||||
'messages': messages,
|
||||
'tools': tools,
|
||||
}
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('jsonl_path', type=str)
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = os.path.dirname(args.jsonl_path)
|
||||
output_path = os.path.join(output_dir, 'output.with_completions.jsonl.gz')
|
||||
|
||||
# Check if output would be different from input
|
||||
needs_update = False
|
||||
with open(args.jsonl_path, 'r') as f_in:
|
||||
for line in tqdm(f_in, desc='Checking for changes'):
|
||||
data = json.loads(line)
|
||||
new_completions = load_completions(output_dir, data['instance_id'])
|
||||
current_completions = data.get('raw_completions')
|
||||
if current_completions != new_completions:
|
||||
needs_update = True
|
||||
break
|
||||
|
||||
if not needs_update:
|
||||
print('No updates required. Skipping file update.')
|
||||
exit(0)
|
||||
|
||||
if os.path.exists(output_path):
|
||||
print(f'Output file already exists at {output_path}, overwriting? (y/n)')
|
||||
if input() != 'y':
|
||||
print('Exiting...')
|
||||
exit(0)
|
||||
|
||||
# Process line by line
|
||||
with open(args.jsonl_path, 'r') as f_in, gzip.open(output_path, 'wt') as f_out:
|
||||
for line in tqdm(f_in):
|
||||
data = json.loads(line)
|
||||
data['raw_completions'] = load_completions(output_dir, data['instance_id'])
|
||||
f_out.write(json.dumps(data) + '\n')
|
||||
|
||||
print(f'Saved compressed output to {output_path}')
|
||||
@@ -22,7 +22,8 @@ def convert_row_to_swebench_format(row):
|
||||
elif 'test_result' in row and 'git_patch' in row['test_result']:
|
||||
model_patch = row['test_result']['git_patch']
|
||||
else:
|
||||
raise ValueError(f'Row {row} does not have a git_patch')
|
||||
print(f'WARNING: Row {row} does not have a git_patch')
|
||||
model_patch = ''
|
||||
|
||||
return {
|
||||
'instance_id': row['instance_id'],
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('input_file', type=str)
|
||||
@@ -11,8 +11,7 @@ args = parser.parse_args()
|
||||
|
||||
dirname = os.path.dirname(args.input_file)
|
||||
|
||||
df = pd.read_json(args.input_file, lines=True)
|
||||
|
||||
# Initialize counters and data structures
|
||||
instance_id_to_status = defaultdict(
|
||||
lambda: {
|
||||
'empty_generation': False,
|
||||
@@ -23,15 +22,7 @@ instance_id_to_status = defaultdict(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Apply the status to the dataframe
|
||||
def apply_report(row):
|
||||
instance_id = row['instance_id']
|
||||
if instance_id in instance_id_to_status:
|
||||
return dict(instance_id_to_status[instance_id])
|
||||
return row.get('report', {})
|
||||
|
||||
|
||||
# Process official report if it exists
|
||||
swebench_official_report_json = os.path.join(dirname, 'report.json')
|
||||
openhands_remote_report_jsonl = args.input_file.replace(
|
||||
'.jsonl', '.swebench_eval.jsonl'
|
||||
@@ -90,113 +81,159 @@ if os.path.exists(swebench_official_report_json):
|
||||
f'- [{instance_id}](./eval_outputs/{instance_id}/run_instance.log)\n'
|
||||
)
|
||||
|
||||
df['report'] = df.apply(apply_report, axis=1)
|
||||
|
||||
with open(output_md_filepath, 'w') as f:
|
||||
f.write(output_md)
|
||||
|
||||
elif os.path.exists(openhands_remote_report_jsonl):
|
||||
output_md_filepath = args.input_file.replace('.jsonl', '.swebench_eval.md')
|
||||
|
||||
df_eval = pd.read_json(openhands_remote_report_jsonl, lines=True, orient='records')
|
||||
# First pass: Read eval report and count instances
|
||||
instance_ids = set()
|
||||
eval_instance_ids = set()
|
||||
|
||||
assert len(df['instance_id'].unique()) == len(
|
||||
df
|
||||
), 'There are duplicate instance ids in the original output which is not allowed'
|
||||
assert len(df_eval['instance_id'].unique()) == len(
|
||||
df_eval
|
||||
), 'There are duplicate instance ids in the eval report which is not allowed'
|
||||
# Count instances in original file
|
||||
n_instances = 0
|
||||
with open(args.input_file, 'r') as f:
|
||||
for line in tqdm(f, desc='Counting instances in original file'):
|
||||
data = json.loads(line)
|
||||
instance_ids.add(data['instance_id'])
|
||||
n_instances += 1
|
||||
print(f'Total instances in original file: {n_instances}')
|
||||
|
||||
for _, row in df_eval.iterrows():
|
||||
instance_id_to_status[row['instance_id']] = row['test_result']['report']
|
||||
df['report'] = df.apply(apply_report, axis=1)
|
||||
# Process eval report
|
||||
n_eval_instances = 0
|
||||
with open(openhands_remote_report_jsonl, 'r') as f:
|
||||
for line in tqdm(f, desc='Processing eval report'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
eval_instance_ids.add(instance_id)
|
||||
n_eval_instances += 1
|
||||
instance_id_to_status[instance_id] = data['test_result']['report']
|
||||
print(f'Total instances in eval report: {n_eval_instances}')
|
||||
|
||||
report_is_dict = df['report'].apply(lambda x: isinstance(x, dict))
|
||||
if not report_is_dict.all():
|
||||
print(df[~report_is_dict])
|
||||
raise ValueError(f'Report is not a dict, but a {type(row["report"])}')
|
||||
# Verify no duplicates
|
||||
assert (
|
||||
len(instance_ids) == n_instances
|
||||
), 'Duplicate instance ids found in original output'
|
||||
assert (
|
||||
len(eval_instance_ids) == n_eval_instances
|
||||
), 'Duplicate instance ids found in eval report'
|
||||
|
||||
_n_instances = len(df)
|
||||
_n_resolved = len(df[df['report'].apply(lambda x: x.get('resolved', False))])
|
||||
_n_unresolved = _n_instances - _n_resolved
|
||||
_n_empty_patch = len(
|
||||
df[df['report'].apply(lambda x: x.get('empty_generation', False))]
|
||||
)
|
||||
_n_error = len(df[df['report'].apply(lambda x: x.get('error_eval', False))])
|
||||
# Initialize counters
|
||||
stats = {'total': len(instance_ids), 'resolved': 0, 'empty_patch': 0, 'error': 0}
|
||||
|
||||
# Collect instance IDs by category
|
||||
resolved_ids = []
|
||||
unresolved_ids = []
|
||||
error_ids = []
|
||||
empty_patch_ids = []
|
||||
timeout_ids = []
|
||||
|
||||
# Process original file and categorize instances
|
||||
with open(args.input_file, 'r') as f:
|
||||
for line in f:
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
report = instance_id_to_status[instance_id]
|
||||
|
||||
if report.get('resolved', False):
|
||||
stats['resolved'] += 1
|
||||
resolved_ids.append(instance_id)
|
||||
else:
|
||||
unresolved_ids.append(instance_id)
|
||||
|
||||
if report.get('empty_generation', False):
|
||||
stats['empty_patch'] += 1
|
||||
empty_patch_ids.append(instance_id)
|
||||
if report.get('error_eval', False):
|
||||
stats['error'] += 1
|
||||
error_ids.append(instance_id)
|
||||
if report.get('test_timeout', False):
|
||||
timeout_ids.append(instance_id)
|
||||
|
||||
# Generate markdown report
|
||||
def _instance_id_to_log_path(instance_id):
|
||||
path = f"{args.input_file.replace('.jsonl', '.swebench_eval.logs')}/instance_{instance_id}.log"
|
||||
return os.path.relpath(path, start=dirname)
|
||||
|
||||
# ... rest of markdown generation code remains the same ...
|
||||
output_md = (
|
||||
'# SWE-bench Report\n'
|
||||
'This folder contains the evaluation results of the SWE-bench using the [official evaluation docker containerization](https://github.com/princeton-nlp/SWE-bench/blob/main/docs/20240627_docker/README.md#choosing-the-right-cache_level).\n\n'
|
||||
'## Summary\n'
|
||||
f'- submitted instances: {_n_instances}\n'
|
||||
f'- empty patch instances: {_n_empty_patch}\n'
|
||||
f'- resolved instances: {_n_resolved}\n'
|
||||
f'- unresolved instances: {_n_unresolved}\n'
|
||||
f'- error instances: {_n_error}\n'
|
||||
f'- submitted instances: {stats["total"]}\n'
|
||||
f'- empty patch instances: {stats["empty_patch"]}\n'
|
||||
f'- resolved instances: {stats["resolved"]}\n'
|
||||
f'- unresolved instances: {len(unresolved_ids)}\n'
|
||||
f'- error instances: {stats["error"]}\n'
|
||||
)
|
||||
|
||||
def _instance_id_to_log_path(instance_id):
|
||||
path = f"{args.input_file.replace('.jsonl', '.swebench_eval.logs')}/instance_{instance_id}.log"
|
||||
# make it relative path
|
||||
path = os.path.relpath(path, start=dirname)
|
||||
return path
|
||||
|
||||
output_md += '\n## Resolved Instances\n'
|
||||
# instance_id to status
|
||||
for instance_id in sorted(
|
||||
df[df['report'].apply(lambda x: x.get('resolved', False))][
|
||||
'instance_id'
|
||||
].unique()
|
||||
):
|
||||
for instance_id in resolved_ids:
|
||||
instance_id_to_status[instance_id]['resolved'] = True
|
||||
output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
|
||||
|
||||
output_md += '\n## Unresolved Instances\n'
|
||||
for instance_id in sorted(
|
||||
df[~df['report'].apply(lambda x: x.get('resolved', False))][
|
||||
'instance_id'
|
||||
].unique()
|
||||
):
|
||||
for instance_id in unresolved_ids:
|
||||
output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
|
||||
|
||||
output_md += '\n## Error Instances\n'
|
||||
for instance_id in sorted(
|
||||
df[df['report'].apply(lambda x: x.get('error_eval', False))][
|
||||
'instance_id'
|
||||
].unique()
|
||||
):
|
||||
for instance_id in error_ids:
|
||||
instance_id_to_status[instance_id]['error_eval'] = True
|
||||
output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
|
||||
|
||||
output_md += '\n## Empty Patch Instances\n'
|
||||
for instance_id in sorted(
|
||||
df[df['report'].apply(lambda x: x.get('empty_generation', False))][
|
||||
'instance_id'
|
||||
].unique()
|
||||
):
|
||||
for instance_id in empty_patch_ids:
|
||||
instance_id_to_status[instance_id]['empty_generation'] = True
|
||||
output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
|
||||
|
||||
output_md += '\n## Incomplete Instances\n'
|
||||
for instance_id in sorted(
|
||||
df[df['report'].apply(lambda x: x.get('test_timeout', False))][
|
||||
'instance_id'
|
||||
].unique()
|
||||
):
|
||||
for instance_id in timeout_ids:
|
||||
output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
|
||||
|
||||
with open(output_md_filepath, 'w') as f:
|
||||
f.write(output_md)
|
||||
|
||||
else:
|
||||
print(
|
||||
f'No report file found: Both {swebench_official_report_json} and {openhands_remote_report_jsonl} do not exist.'
|
||||
)
|
||||
exit()
|
||||
|
||||
# Before backup and update, check if any changes would be made
|
||||
needs_update = False
|
||||
with open(args.input_file, 'r') as infile:
|
||||
for line in tqdm(infile, desc='Checking for changes'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
if instance_id in instance_id_to_status:
|
||||
current_report = data.get('report', {})
|
||||
new_report = instance_id_to_status[instance_id]
|
||||
if current_report != new_report:
|
||||
needs_update = True
|
||||
break
|
||||
|
||||
if not needs_update:
|
||||
print('No updates detected. Skipping file update.')
|
||||
exit()
|
||||
|
||||
# Backup and update the original file row by row
|
||||
if os.path.exists(args.input_file + '.bak'):
|
||||
conf = input('Existing backup file found. Do you want to overwrite it? (y/n)')
|
||||
if conf != 'y':
|
||||
exit()
|
||||
os.remove(args.input_file + '.bak')
|
||||
|
||||
# backup the original file
|
||||
os.rename(args.input_file, args.input_file + '.bak')
|
||||
df.to_json(args.input_file, orient='records', lines=True)
|
||||
|
||||
# Process and write file row by row
|
||||
with open(args.input_file + '.bak', 'r') as infile, open(
|
||||
args.input_file, 'w'
|
||||
) as outfile:
|
||||
for line in tqdm(infile, desc='Updating output file'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
if instance_id in instance_id_to_status:
|
||||
data['report'] = instance_id_to_status[instance_id]
|
||||
outfile.write(json.dumps(data) + '\n')
|
||||
|
||||
@@ -108,7 +108,14 @@ if [ -z "$N_RUNS" ]; then
|
||||
echo "N_RUNS not specified, use default $N_RUNS"
|
||||
fi
|
||||
|
||||
# Skip runs if the run number is in the SKIP_RUNS list
|
||||
# read from env variable SKIP_RUNS as a comma separated list of run numbers
|
||||
SKIP_RUNS=(${SKIP_RUNS//,/ })
|
||||
for i in $(seq 1 $N_RUNS); do
|
||||
if [[ " ${SKIP_RUNS[@]} " =~ " $i " ]]; then
|
||||
echo "Skipping run $i"
|
||||
continue
|
||||
fi
|
||||
current_eval_note="$EVAL_NOTE-run_$i"
|
||||
echo "EVAL_NOTE: $current_eval_note"
|
||||
run_eval $current_eval_note
|
||||
|
||||
@@ -262,7 +262,7 @@ def pre_login(
|
||||
instruction = action.to_instruction()
|
||||
|
||||
browser_action = BrowseInteractiveAction(browser_actions=instruction)
|
||||
browser_action.timeout = 10000
|
||||
browser_action.set_hard_timeout(10000)
|
||||
logger.info(browser_action, extra={'msg_type': 'ACTION'})
|
||||
obs: BrowserOutputObservation = runtime.run_action(browser_action)
|
||||
logger.debug(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
@@ -80,13 +80,13 @@ def load_dependencies(runtime: Runtime) -> List[str]:
|
||||
def init_task_env(runtime: Runtime, hostname: str, env_llm_config: LLMConfig):
|
||||
command = (
|
||||
f'SERVER_HOSTNAME={hostname} '
|
||||
f'LITELLM_API_KEY={env_llm_config.api_key} '
|
||||
f'LITELLM_API_KEY={env_llm_config.api_key.get_secret_value() if env_llm_config.api_key else None} '
|
||||
f'LITELLM_BASE_URL={env_llm_config.base_url} '
|
||||
f'LITELLM_MODEL={env_llm_config.model} '
|
||||
'bash /utils/init.sh'
|
||||
)
|
||||
action = CmdRunAction(command=command)
|
||||
action.timeout = 900
|
||||
action.set_hard_timeout(900)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -165,14 +165,14 @@ def run_evaluator(
|
||||
runtime: Runtime, env_llm_config: LLMConfig, trajectory_path: str, result_path: str
|
||||
):
|
||||
command = (
|
||||
f'LITELLM_API_KEY={env_llm_config.api_key} '
|
||||
f'LITELLM_API_KEY={env_llm_config.api_key.get_secret_value() if env_llm_config.api_key else None} '
|
||||
f'LITELLM_BASE_URL={env_llm_config.base_url} '
|
||||
f'LITELLM_MODEL={env_llm_config.model} '
|
||||
f"DECRYPTION_KEY='theagentcompany is all you need' " # Hardcoded Key
|
||||
f'python_default /utils/eval.py --trajectory_path {trajectory_path} --result_path {result_path}'
|
||||
)
|
||||
action = CmdRunAction(command=command)
|
||||
action.timeout = 600
|
||||
action.set_hard_timeout(600)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
@@ -57,7 +57,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
674
evaluation/benchmarks/visualcodebench/eval.py
Normal file
674
evaluation/benchmarks/visualcodebench/eval.py
Normal file
@@ -0,0 +1,674 @@
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from difflib import SequenceMatcher
|
||||
from io import BytesIO
|
||||
|
||||
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from colormath.color_conversions import convert_color
|
||||
from colormath.color_diff import delta_e_cie2000
|
||||
from colormath.color_objects import LabColor, sRGBColor
|
||||
from PIL import Image, ImageChops, ImageColor
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def calculate_similarity(block1, block2):
|
||||
"""Calculate text similarity between two blocks using SequenceMatcher."""
|
||||
text_similarity = SequenceMatcher(None, block1['text'], block2['text']).ratio()
|
||||
return text_similarity
|
||||
|
||||
|
||||
def adjust_cost_for_context(cost_matrix, consecutive_bonus=1.0, window_size=20):
|
||||
"""Adjust cost matrix by considering context similarity."""
|
||||
if window_size <= 0:
|
||||
return cost_matrix
|
||||
|
||||
n, m = cost_matrix.shape
|
||||
adjusted_cost_matrix = np.copy(cost_matrix)
|
||||
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
if adjusted_cost_matrix[i][j] >= -0.5:
|
||||
continue
|
||||
nearby_matrix = cost_matrix[
|
||||
max(0, i - window_size) : min(n, i + window_size + 1),
|
||||
max(0, j - window_size) : min(m, j + window_size + 1),
|
||||
]
|
||||
flattened_array = nearby_matrix.flatten()
|
||||
sorted_array = np.sort(flattened_array)[::-1]
|
||||
sorted_array = np.delete(
|
||||
sorted_array, np.where(sorted_array == cost_matrix[i, j])[0][0]
|
||||
)
|
||||
top_k_elements = sorted_array[-window_size * 2 :]
|
||||
bonus = consecutive_bonus * np.sum(top_k_elements)
|
||||
adjusted_cost_matrix[i][j] += bonus
|
||||
return adjusted_cost_matrix
|
||||
|
||||
|
||||
def create_cost_matrix(A, B):
|
||||
"""Create cost matrix for block matching."""
|
||||
n = len(A)
|
||||
m = len(B)
|
||||
cost_matrix = np.zeros((n, m))
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
cost_matrix[i, j] = -calculate_similarity(A[i], B[j])
|
||||
return cost_matrix
|
||||
|
||||
|
||||
def calculate_distance_max_1d(x1, y1, x2, y2):
|
||||
"""Calculate maximum 1D distance between points."""
|
||||
return max(abs(x2 - x1), abs(y2 - y1))
|
||||
|
||||
|
||||
def calculate_ratio(h1, h2):
|
||||
"""Calculate ratio between two heights."""
|
||||
return max(h1, h2) / min(h1, h2)
|
||||
|
||||
|
||||
def rgb_to_lab(rgb):
|
||||
"""Convert RGB color to Lab color space."""
|
||||
rgb_color = sRGBColor(rgb[0], rgb[1], rgb[2], is_upscaled=True)
|
||||
lab_color = convert_color(rgb_color, LabColor)
|
||||
return lab_color
|
||||
|
||||
|
||||
def color_similarity_ciede2000(rgb1, rgb2):
|
||||
"""Calculate color similarity using CIEDE2000 formula."""
|
||||
lab1 = rgb_to_lab(rgb1)
|
||||
lab2 = rgb_to_lab(rgb2)
|
||||
delta_e = delta_e_cie2000(lab1, lab2)
|
||||
similarity = max(0, 1 - (delta_e / 100))
|
||||
return similarity
|
||||
|
||||
|
||||
def merge_blocks_wo_check(block1, block2):
|
||||
"""Merge two blocks without additional checks."""
|
||||
merged_text = block1['text'] + ' ' + block2['text']
|
||||
x_min = min(block1['bbox'][0], block2['bbox'][0])
|
||||
y_min = min(block1['bbox'][1], block2['bbox'][1])
|
||||
x_max = max(
|
||||
block1['bbox'][0] + block1['bbox'][2], block2['bbox'][0] + block2['bbox'][2]
|
||||
)
|
||||
y_max = max(
|
||||
block1['bbox'][1] + block1['bbox'][3], block2['bbox'][1] + block2['bbox'][3]
|
||||
)
|
||||
merged_bbox = (x_min, y_min, x_max - x_min, y_max - y_min)
|
||||
merged_color = tuple(
|
||||
(color1 + color2) // 2
|
||||
for color1, color2 in zip(block1['color'], block2['color'])
|
||||
)
|
||||
return {'text': merged_text, 'bbox': merged_bbox, 'color': merged_color}
|
||||
|
||||
|
||||
def find_maximum_matching(A, B, consecutive_bonus, window_size):
|
||||
"""Find maximum matching between two sets of blocks."""
|
||||
cost_matrix = create_cost_matrix(A, B)
|
||||
cost_matrix = adjust_cost_for_context(cost_matrix, consecutive_bonus, window_size)
|
||||
row_ind, col_ind = linear_sum_assignment(cost_matrix)
|
||||
current_cost = cost_matrix[row_ind, col_ind].tolist()
|
||||
return list(zip(row_ind, col_ind)), current_cost, cost_matrix
|
||||
|
||||
|
||||
def remove_indices(lst, indices):
|
||||
"""Remove indices from list in reverse order."""
|
||||
for index in sorted(indices, reverse=True):
|
||||
if index < len(lst):
|
||||
lst.pop(index)
|
||||
return lst
|
||||
|
||||
|
||||
def merge_blocks_by_list(blocks, merge_list):
|
||||
"""Merge blocks according to merge list."""
|
||||
pop_list = []
|
||||
while merge_list:
|
||||
i = merge_list[0][0]
|
||||
j = merge_list[0][1]
|
||||
blocks[i] = merge_blocks_wo_check(blocks[i], blocks[j])
|
||||
pop_list.append(j)
|
||||
merge_list.pop(0)
|
||||
if merge_list:
|
||||
new_merge_list = []
|
||||
for k in range(len(merge_list)):
|
||||
if (
|
||||
merge_list[k][0] != i
|
||||
and merge_list[k][1] != i
|
||||
and merge_list[k][0] != j
|
||||
and merge_list[k][1] != j
|
||||
):
|
||||
new_merge_list.append(merge_list[k])
|
||||
merge_list = new_merge_list
|
||||
remove_indices(blocks, pop_list)
|
||||
return blocks
|
||||
|
||||
|
||||
def difference_of_means(list1, list2):
|
||||
"""Calculate difference of means between two lists."""
|
||||
counter1 = Counter(list1)
|
||||
counter2 = Counter(list2)
|
||||
|
||||
for element in set(list1) & set(list2):
|
||||
common_count = min(counter1[element], counter2[element])
|
||||
counter1[element] -= common_count
|
||||
counter2[element] -= common_count
|
||||
|
||||
unique_list1 = [item for item in counter1.elements()]
|
||||
unique_list2 = [item for item in counter2.elements()]
|
||||
|
||||
mean_list1 = sum(unique_list1) / len(unique_list1) if unique_list1 else 0
|
||||
mean_list2 = sum(unique_list2) / len(unique_list2) if unique_list2 else 0
|
||||
|
||||
if mean_list1 - mean_list2 > 0:
|
||||
if min(unique_list1) > min(unique_list2):
|
||||
return mean_list1 - mean_list2
|
||||
return 0.0
|
||||
return mean_list1 - mean_list2
|
||||
|
||||
|
||||
def find_possible_merge(A, B, consecutive_bonus, window_size, debug=False):
|
||||
"""Find possible merges between blocks."""
|
||||
merge_bonus = 0.0
|
||||
merge_windows = 1
|
||||
|
||||
def sortFn(value):
|
||||
return value[2]
|
||||
|
||||
while True:
|
||||
A_changed = False
|
||||
B_changed = False
|
||||
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if len(A) >= 2:
|
||||
merge_list = []
|
||||
for i in range(len(A) - 1):
|
||||
new_A = deepcopy(A)
|
||||
new_A[i] = merge_blocks_wo_check(new_A[i], new_A[i + 1])
|
||||
new_A.pop(i + 1)
|
||||
updated_matching, updated_cost, _ = find_maximum_matching(
|
||||
new_A, B, merge_bonus, merge_windows
|
||||
)
|
||||
diff = difference_of_means(current_cost, updated_cost)
|
||||
if diff > 0.05:
|
||||
merge_list.append([i, i + 1, diff])
|
||||
|
||||
merge_list.sort(key=sortFn, reverse=True)
|
||||
if merge_list:
|
||||
A_changed = True
|
||||
A = merge_blocks_by_list(A, merge_list)
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if len(B) >= 2:
|
||||
merge_list = []
|
||||
for i in range(len(B) - 1):
|
||||
new_B = deepcopy(B)
|
||||
new_B[i] = merge_blocks_wo_check(new_B[i], new_B[i + 1])
|
||||
new_B.pop(i + 1)
|
||||
updated_matching, updated_cost, _ = find_maximum_matching(
|
||||
A, new_B, merge_bonus, merge_windows
|
||||
)
|
||||
diff = difference_of_means(current_cost, updated_cost)
|
||||
if diff > 0.05:
|
||||
merge_list.append([i, i + 1, diff])
|
||||
|
||||
merge_list.sort(key=sortFn, reverse=True)
|
||||
if merge_list:
|
||||
B_changed = True
|
||||
B = merge_blocks_by_list(B, merge_list)
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if not A_changed and not B_changed:
|
||||
break
|
||||
|
||||
matching, _, _ = find_maximum_matching(A, B, consecutive_bonus, window_size)
|
||||
return A, B, matching
|
||||
|
||||
|
||||
def merge_blocks_by_bbox(blocks):
|
||||
"""Merge blocks with same bounding box."""
|
||||
merged_blocks = {}
|
||||
for block in blocks:
|
||||
bbox = tuple(block['bbox'])
|
||||
if bbox in merged_blocks:
|
||||
existing_block = merged_blocks[bbox]
|
||||
existing_block['text'] += ' ' + block['text']
|
||||
existing_block['color'] = [
|
||||
(ec + c) / 2 for ec, c in zip(existing_block['color'], block['color'])
|
||||
]
|
||||
else:
|
||||
merged_blocks[bbox] = block
|
||||
return list(merged_blocks.values())
|
||||
|
||||
|
||||
def mask_bounding_boxes_with_inpainting(image, bounding_boxes):
|
||||
"""Mask bounding boxes in image using inpainting."""
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
||||
mask = np.zeros(image_cv.shape[:2], dtype=np.uint8)
|
||||
height, width = image_cv.shape[:2]
|
||||
|
||||
for bbox in bounding_boxes:
|
||||
x_ratio, y_ratio, w_ratio, h_ratio = bbox
|
||||
x = int(x_ratio * width)
|
||||
y = int(y_ratio * height)
|
||||
w = int(w_ratio * width)
|
||||
h = int(h_ratio * height)
|
||||
mask[y : y + h, x : x + w] = 255
|
||||
|
||||
inpainted_image = cv2.inpaint(image_cv, mask, 3, cv2.INPAINT_TELEA)
|
||||
return Image.fromarray(cv2.cvtColor(inpainted_image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
|
||||
def rescale_and_mask(image, blocks):
|
||||
"""Rescale image and mask blocks."""
|
||||
if blocks:
|
||||
image = mask_bounding_boxes_with_inpainting(image, blocks)
|
||||
|
||||
width, height = image.size
|
||||
if width < height:
|
||||
new_size = (width, width)
|
||||
else:
|
||||
new_size = (height, height)
|
||||
|
||||
return image.resize(new_size, Image.LANCZOS)
|
||||
|
||||
|
||||
def calculate_clip_similarity(image1, image2, blocks1, blocks2):
|
||||
"""Calculate CLIP similarity between two images."""
|
||||
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
|
||||
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
|
||||
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||
model = model.to(device)
|
||||
|
||||
# Mask and preprocess images
|
||||
image1_masked = rescale_and_mask(image1, [block['bbox'] for block in blocks1])
|
||||
image2_masked = rescale_and_mask(image2, [block['bbox'] for block in blocks2])
|
||||
inputs = processor(
|
||||
images=[image1_masked, image2_masked], return_tensors='pt', padding=True
|
||||
)
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
# Calculate features and similarity
|
||||
with torch.no_grad():
|
||||
image_features = model.get_image_features(**inputs)
|
||||
image_features1 = image_features[0].unsqueeze(0)
|
||||
image_features2 = image_features[1].unsqueeze(0)
|
||||
image_features1 /= image_features1.norm(dim=-1, keepdim=True)
|
||||
image_features2 /= image_features2.norm(dim=-1, keepdim=True)
|
||||
similarity = (image_features1 @ image_features2.T).item()
|
||||
|
||||
return similarity
|
||||
|
||||
|
||||
def rgb_to_hex(rgb):
|
||||
"""Convert an RGB tuple to hexadecimal format."""
|
||||
return '{:02X}{:02X}{:02X}'.format(*rgb)
|
||||
|
||||
|
||||
class ColorPool:
|
||||
def __init__(self, offset=0):
|
||||
color_values = list(range(10, 251, 16))
|
||||
color_list = [((r + offset) % 256, (g + offset) % 256, (b + offset) % 256)
|
||||
for r in color_values for g in color_values for b in color_values]
|
||||
self.color_pool = [rgb_to_hex(color) for color in color_list]
|
||||
|
||||
def pop_color(self):
|
||||
if self.color_pool:
|
||||
return self.color_pool.pop()
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def process_html_str(html_str, offset=0):
|
||||
"""Process HTML string to assign unique colors to text elements."""
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
|
||||
def update_style(element, property_name, value):
|
||||
important_value = f"{value} !important"
|
||||
styles = element.attrs.get('style', '').split(';')
|
||||
updated_styles = [s for s in styles if not s.strip().startswith(property_name) and len(s.strip()) > 0]
|
||||
updated_styles.append(f"{property_name}: {important_value}")
|
||||
element['style'] = '; '.join(updated_styles).strip()
|
||||
|
||||
# Set background color of all elements to transparent white
|
||||
for element in soup.find_all(True):
|
||||
update_style(element, 'background-color', 'rgba(255, 255, 255, 0.0)')
|
||||
|
||||
color_pool = ColorPool(offset)
|
||||
text_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'a', 'b', 'li',
|
||||
'table', 'td', 'th', 'button', 'footer', 'header', 'figcaption']
|
||||
|
||||
for tag in soup.find_all(text_tags):
|
||||
color = f"#{color_pool.pop_color()}"
|
||||
update_style(tag, 'color', color)
|
||||
update_style(tag, 'opacity', '1.0')
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def similar(n1, n2):
|
||||
"""Check if two numbers are similar within a threshold."""
|
||||
return abs(n1 - n2) <= 8
|
||||
|
||||
|
||||
def find_different_pixels(image1, image2):
|
||||
"""Find pixels that differ between two images."""
|
||||
if image1.size != image2.size:
|
||||
logger.warning("Images are not the same size")
|
||||
return None
|
||||
|
||||
image1 = image1.convert('RGB')
|
||||
image2 = image2.convert('RGB')
|
||||
pixels1 = image1.load()
|
||||
pixels2 = image2.load()
|
||||
different_pixels = []
|
||||
|
||||
for x in range(image1.size[0]):
|
||||
for y in range(image1.size[1]):
|
||||
r1, g1, b1 = pixels1[x, y]
|
||||
r2, g2, b2 = pixels2[x, y]
|
||||
if similar((r1 + 50) % 256, r2) and similar((g1 + 50) % 256, g2) and similar((b1 + 50) % 256, b2):
|
||||
different_pixels.append((y, x))
|
||||
|
||||
return np.stack(different_pixels) if different_pixels else None
|
||||
|
||||
|
||||
def extract_text_with_color(html_str):
|
||||
"""Extract text and color information from HTML string."""
|
||||
def get_color(tag):
|
||||
if 'style' in tag.attrs:
|
||||
styles = tag['style'].split(';')
|
||||
color_style = [s for s in styles if 'color' in s and 'background-color' not in s]
|
||||
if color_style:
|
||||
color = color_style[-1].split(':')[1].strip().replace(" !important", "")
|
||||
if color[0] == "#":
|
||||
return color
|
||||
else:
|
||||
try:
|
||||
if color.startswith('rgb'):
|
||||
color = tuple(map(int, color[4:-1].split(',')))
|
||||
else:
|
||||
color = ImageColor.getrgb(color)
|
||||
return '#{:02x}{:02x}{:02x}'.format(*color)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to identify or convert color: {color}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def extract_text_recursive(element, parent_color='#000000'):
|
||||
if isinstance(element, Comment):
|
||||
return None
|
||||
elif isinstance(element, NavigableString):
|
||||
text = element.strip()
|
||||
return (text, parent_color) if text else None
|
||||
elif isinstance(element, Tag):
|
||||
current_color = get_color(element) or parent_color
|
||||
children_texts = filter(None, [extract_text_recursive(child, current_color)
|
||||
for child in element.children])
|
||||
return list(children_texts)
|
||||
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
body = soup.body
|
||||
return extract_text_recursive(body) if body else []
|
||||
|
||||
|
||||
def flatten_tree(tree):
|
||||
"""Flatten a nested tree structure into a list."""
|
||||
flat_list = []
|
||||
def flatten(node):
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
flatten(item)
|
||||
else:
|
||||
flat_list.append(node)
|
||||
flatten(tree)
|
||||
return flat_list
|
||||
|
||||
|
||||
def get_blocks_from_image_diff_pixels(image, html_text_color_tree, different_pixels):
|
||||
"""Extract text blocks from image using color differences."""
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
||||
x_w = image_cv.shape[0]
|
||||
y_w = image_cv.shape[1]
|
||||
|
||||
def hex_to_bgr(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
return rgb[::-1]
|
||||
|
||||
def get_intersect(arr1, arr2):
|
||||
arr1_reshaped = arr1.view([('', arr1.dtype)] * arr1.shape[1])
|
||||
arr2_reshaped = arr2.view([('', arr2.dtype)] * arr2.shape[1])
|
||||
common_rows = np.intersect1d(arr1_reshaped, arr2_reshaped)
|
||||
return common_rows.view(arr1.dtype).reshape(-1, arr1.shape[1])
|
||||
|
||||
blocks = []
|
||||
for item in html_text_color_tree:
|
||||
try:
|
||||
color = np.array(hex_to_bgr(item[1]), dtype="uint8")
|
||||
except:
|
||||
continue
|
||||
|
||||
lower = color - 4
|
||||
upper = color + 4
|
||||
mask = cv2.inRange(image_cv, lower, upper)
|
||||
coords = np.column_stack(np.where(mask > 0))
|
||||
coords = get_intersect(coords, different_pixels)
|
||||
|
||||
if coords.size == 0:
|
||||
continue
|
||||
|
||||
x_min, y_min = np.min(coords, axis=0)
|
||||
x_max, y_max = np.max(coords, axis=0)
|
||||
|
||||
# Get average color from original image
|
||||
color_coords = coords.copy()
|
||||
color_coords = color_coords[color_coords[:, 0] <= x_max]
|
||||
color_coords = color_coords[color_coords[:, 1] <= y_max]
|
||||
colors = [image_cv[x, y] for x, y in color_coords]
|
||||
avg_color = tuple(map(int, np.mean(colors, axis=0)))[::-1] # Convert BGR to RGB
|
||||
|
||||
blocks.append({
|
||||
'text': item[0].lower(),
|
||||
'bbox': (y_min / y_w, x_min / x_w, (y_max - y_min + 1) / y_w, (x_max - x_min + 1) / x_w),
|
||||
'color': avg_color
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def get_blocks_from_html(html_str, image1):
|
||||
"""Extract text blocks from HTML and image."""
|
||||
# Process HTML with two different color offsets
|
||||
html_str_1 = process_html_str(html_str, offset=0)
|
||||
html_str_2 = process_html_str(html_str, offset=50)
|
||||
|
||||
# Render both HTML versions to images
|
||||
# TODO: Screenshot html_str_2
|
||||
filter_color = (255, 0, 0)
|
||||
image2 = Image.new("RGB", image1.size, filter_color)
|
||||
|
||||
|
||||
# Find pixels that differ between the two rendered images
|
||||
different_pixels = find_different_pixels(image1, image2)
|
||||
if different_pixels is None:
|
||||
logger.warning("Unable to get pixels with different colors")
|
||||
return []
|
||||
|
||||
# Extract text and color information from HTML
|
||||
html_text_color_tree = flatten_tree(extract_text_with_color(html_str_1))
|
||||
try:
|
||||
blocks = get_blocks_from_image_diff_pixels(image1, html_text_color_tree, different_pixels)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get blocks: {e}")
|
||||
return []
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def evaluate(task, generated_img):
|
||||
"""Evaluate generated image against reference image using multiple metrics."""
|
||||
# Load reference image
|
||||
post_image = task['post_image']
|
||||
|
||||
# Extract blocks from HTML and images
|
||||
post_blocks = get_blocks_from_html(task['post_html'], post_image)
|
||||
gen_blocks = get_blocks_from_html(task['gen_html'], generated_img)
|
||||
|
||||
print("block details", post_blocks, gen_blocks)
|
||||
if not post_blocks or not gen_blocks:
|
||||
# Fallback to basic CLIP and pixel comparison if no blocks available
|
||||
clip_score = calculate_clip_similarity(post_image, generated_img, [], [])
|
||||
logger.info(f'CLIP similarity score: {clip_score}')
|
||||
|
||||
# Pixel comparison
|
||||
diff = ImageChops.difference(generated_img, post_image)
|
||||
pixel_match = not diff.getbbox()
|
||||
logger.info(
|
||||
f"Pixel difference analysis: {'No difference' if pixel_match else 'Differences found'}"
|
||||
)
|
||||
|
||||
return clip_score > 0.95 or pixel_match
|
||||
|
||||
# Merge blocks with same bounding boxes
|
||||
post_blocks = merge_blocks_by_bbox(post_blocks)
|
||||
gen_blocks = merge_blocks_by_bbox(gen_blocks)
|
||||
|
||||
# Find optimal block matching
|
||||
consecutive_bonus, window_size = 0.1, 1
|
||||
gen_blocks_m, post_blocks_m, matching = find_possible_merge(
|
||||
gen_blocks, deepcopy(post_blocks), consecutive_bonus, window_size
|
||||
)
|
||||
|
||||
# Filter matches with low similarity
|
||||
filtered_matching = []
|
||||
for i, j in matching:
|
||||
text_similarity = calculate_similarity(gen_blocks_m[i], post_blocks_m[j])
|
||||
if text_similarity >= 0.5:
|
||||
filtered_matching.append([i, j, text_similarity])
|
||||
matching = filtered_matching
|
||||
|
||||
if not matching:
|
||||
logger.warning('No matching blocks found')
|
||||
clip_score = calculate_clip_similarity(
|
||||
post_image, generated_img, gen_blocks, post_blocks
|
||||
)
|
||||
return clip_score > 0.95
|
||||
|
||||
# Calculate metrics for matched blocks
|
||||
indices1 = [item[0] for item in matching]
|
||||
indices2 = [item[1] for item in matching]
|
||||
|
||||
# Calculate unmatched areas
|
||||
unmatched_area_1 = sum(
|
||||
block['bbox'][2] * block['bbox'][3]
|
||||
for i, block in enumerate(gen_blocks_m)
|
||||
if i not in indices1
|
||||
)
|
||||
unmatched_area_2 = sum(
|
||||
block['bbox'][2] * block['bbox'][3]
|
||||
for j, block in enumerate(post_blocks_m)
|
||||
if j not in indices2
|
||||
)
|
||||
total_unmatched_area = unmatched_area_1 + unmatched_area_2
|
||||
|
||||
# Calculate metrics for matched blocks
|
||||
matched_areas = []
|
||||
text_scores = []
|
||||
position_scores = []
|
||||
color_scores = []
|
||||
|
||||
for i, j, text_similarity in matching:
|
||||
# Area
|
||||
block_area = (
|
||||
gen_blocks_m[i]['bbox'][2] * gen_blocks_m[i]['bbox'][3]
|
||||
+ post_blocks_m[j]['bbox'][2] * post_blocks_m[j]['bbox'][3]
|
||||
)
|
||||
matched_areas.append(block_area)
|
||||
|
||||
# Position similarity
|
||||
position_similarity = 1 - calculate_distance_max_1d(
|
||||
gen_blocks_m[i]['bbox'][0] + gen_blocks_m[i]['bbox'][2] / 2,
|
||||
gen_blocks_m[i]['bbox'][1] + gen_blocks_m[i]['bbox'][3] / 2,
|
||||
post_blocks_m[j]['bbox'][0] + post_blocks_m[j]['bbox'][2] / 2,
|
||||
post_blocks_m[j]['bbox'][1] + post_blocks_m[j]['bbox'][3] / 2,
|
||||
)
|
||||
|
||||
# Color similarity
|
||||
color_similarity = color_similarity_ciede2000(
|
||||
gen_blocks_m[i]['color'], post_blocks_m[j]['color']
|
||||
)
|
||||
|
||||
text_scores.append(text_similarity)
|
||||
position_scores.append(position_similarity)
|
||||
color_scores.append(color_similarity)
|
||||
|
||||
# Calculate final scores
|
||||
total_area = sum(matched_areas) + total_unmatched_area
|
||||
size_score = sum(matched_areas) / total_area if total_area > 0 else 0
|
||||
text_score = np.mean(text_scores) if text_scores else 0
|
||||
position_score = np.mean(position_scores) if position_scores else 0
|
||||
color_score = np.mean(color_scores) if color_scores else 0
|
||||
clip_score = calculate_clip_similarity(
|
||||
post_image, generated_img, gen_blocks, post_blocks
|
||||
)
|
||||
|
||||
# Combine scores with equal weights
|
||||
final_score = 0.2 * (
|
||||
size_score + text_score + position_score + color_score + clip_score
|
||||
)
|
||||
|
||||
logger.info('Evaluation scores:')
|
||||
logger.info(f'- Size score: {size_score:.3f}')
|
||||
logger.info(f'- Text score: {text_score:.3f}')
|
||||
logger.info(f'- Position score: {position_score:.3f}')
|
||||
logger.info(f'- Color score: {color_score:.3f}')
|
||||
logger.info(f'- CLIP score: {clip_score:.3f}')
|
||||
logger.info(f'- Final score: {final_score:.3f}')
|
||||
|
||||
return final_score > 0.8 # Consider it a match if final score > 80%
|
||||
|
||||
|
||||
def png_to_bytes(png):
|
||||
buffer = BytesIO()
|
||||
png.save(buffer, format='PNG')
|
||||
image_bytes = buffer.getvalue()
|
||||
return image_bytes
|
||||
|
||||
|
||||
def bytes_to_image(image_bytes):
|
||||
"""Convert bytes to a Pillow Image object."""
|
||||
return Image.open(BytesIO(image_bytes))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
first_image = Image.open('./evaluation/visualcodebench/data/1/post.png')
|
||||
image = Image.open('./evaluation/visualcodebench/data/1/prev.png')
|
||||
|
||||
|
||||
html_file = open('./evaluation/visualcodebench/data/1/post/index.html', 'r')
|
||||
first_html = html_file.read()
|
||||
html_file.close()
|
||||
|
||||
html_file = open('./evaluation/visualcodebench/data/1/prev/index.html', 'r')
|
||||
gen_html = html_file.read()
|
||||
html_file.close()
|
||||
|
||||
|
||||
|
||||
sample = {'post_image': first_image, "post_html": first_html, "gen_html": gen_html}
|
||||
|
||||
|
||||
|
||||
evaluate(sample, image)
|
||||
|
||||
97
evaluation/benchmarks/visualcodebench/prepare.py
Normal file
97
evaluation/benchmarks/visualcodebench/prepare.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from huggingface_hub import snapshot_download
|
||||
from PIL import PngImagePlugin
|
||||
from tqdm import tqdm
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
REPO_DOWNLOAD_DIR = (
|
||||
'./evaluation/visualcodebench/' # Directory to store the downloaded repository
|
||||
)
|
||||
|
||||
|
||||
def download_repository():
|
||||
"""
|
||||
Download the entire repository from Hugging Face Hub.
|
||||
This function clones the repository into REPO_DOWNLOAD_DIR.
|
||||
"""
|
||||
repo_id = 'rvmalhot/VisualCodeBench'
|
||||
try:
|
||||
logger.info(f"Downloading repository '{repo_id}'...")
|
||||
snapshot_download(
|
||||
repo_id=repo_id,
|
||||
local_dir=REPO_DOWNLOAD_DIR,
|
||||
repo_type='dataset',
|
||||
ignore_patterns=None, # Download all files
|
||||
)
|
||||
logger.info(f"Repository downloaded to '{REPO_DOWNLOAD_DIR}'.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading repository '{repo_id}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
def format_task_dict(example):
|
||||
instance_id = example['id']
|
||||
prev_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/prev')
|
||||
post_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/post')
|
||||
|
||||
# Check if 'prev' and 'post' directories exist
|
||||
prev_exists = os.path.exists(prev_remote_path)
|
||||
post_exists = os.path.exists(post_remote_path)
|
||||
|
||||
if prev_exists and post_exists:
|
||||
skip = False
|
||||
else:
|
||||
skip = True
|
||||
|
||||
task = {
|
||||
'instance_id': instance_id,
|
||||
'prev_image': example['prev_image'],
|
||||
'post_image': example['post_image'],
|
||||
'changes': example['changes'],
|
||||
'prev_code_files': example['prev_code_files'],
|
||||
'post_code_files': example['post_code_files'],
|
||||
'skip': skip,
|
||||
}
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def prepare_visualcodebench(dataset):
|
||||
logger.info('Processing dataset')
|
||||
dataset_processed = []
|
||||
for example in tqdm(dataset['train']):
|
||||
formatted_example = format_task_dict(example)
|
||||
if formatted_example['skip']:
|
||||
continue
|
||||
del formatted_example['skip']
|
||||
dataset_processed.append(formatted_example)
|
||||
|
||||
return pd.DataFrame(dataset_processed)
|
||||
|
||||
|
||||
def pil_image_to_base64(image: PngImagePlugin.PngImageFile) -> str:
|
||||
"""
|
||||
Converts a PIL image to a Base64-encoded string.
|
||||
|
||||
Parameters:
|
||||
- image (PngImagePlugin.PngImageFile): The PIL image to convert.
|
||||
|
||||
Returns:
|
||||
- str: The Base64-encoded string of the image.
|
||||
"""
|
||||
if not isinstance(image, PngImagePlugin.PngImageFile):
|
||||
raise ValueError(
|
||||
'The provided image is not a PIL.PngImagePlugin.PngImageFile instance.'
|
||||
)
|
||||
|
||||
buffered = BytesIO()
|
||||
image.save(buffered, format='PNG')
|
||||
img_bytes = buffered.getvalue()
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
base64_with_prefix = f'data:image/png;base64,{img_base64}'
|
||||
return [base64_with_prefix]
|
||||
247
evaluation/benchmarks/visualcodebench/run_infer.py
Normal file
247
evaluation/benchmarks/visualcodebench/run_infer.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# FILE: run_infer.py
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import partial
|
||||
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
|
||||
# from evaluation.benchmarks.visualcodebench.eval import capture_screenshot
|
||||
from evaluation.benchmarks.visualcodebench.prepare import (
|
||||
REPO_DOWNLOAD_DIR,
|
||||
download_repository,
|
||||
pil_image_to_base64,
|
||||
prepare_visualcodebench,
|
||||
)
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
)
|
||||
from openhands.core.config.utils import parse_arguments
|
||||
from openhands.core.logger import openhands_logger as logger # Import OpenHands logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.observation.commands import CmdOutputObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
# Define workspace and output directories
|
||||
WORKSPACE_DIR = './workspace'
|
||||
|
||||
FAKE_RESPONSES = {
|
||||
'CodeActAgent': partial(codeact_user_response, encapsulate_solution=True),
|
||||
}
|
||||
|
||||
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='eventstream',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
return config
|
||||
|
||||
|
||||
def initialize_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required
|
||||
):
|
||||
"""Initialize the runtime for the agent.
|
||||
|
||||
This function is called before the runtime is used to run the agent.
|
||||
"""
|
||||
logger.info('-' * 30)
|
||||
logger.info('BEGIN Runtime Initialization Fn')
|
||||
logger.info('-' * 30)
|
||||
workspace_dir_name = instance['instance_id']
|
||||
obs: CmdOutputObservation
|
||||
|
||||
action = CmdRunAction(command='mkdir -p /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(
|
||||
obs.exit_code == 0,
|
||||
f'Failed to create /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
)
|
||||
|
||||
file_path = REPO_DOWNLOAD_DIR + f'data/{workspace_dir_name}/prev/index.html'
|
||||
runtime.copy_to(file_path, f'/workspace/{workspace_dir_name}')
|
||||
logger.info(f'Copied code file for instance {workspace_dir_name}')
|
||||
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(
|
||||
obs.exit_code == 0,
|
||||
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
)
|
||||
|
||||
logger.info('-' * 30)
|
||||
logger.info('END Runtime Initialization Fn')
|
||||
logger.info('-' * 30)
|
||||
|
||||
|
||||
def complete_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required, but it is used to get the workspace_dir_name
|
||||
) -> str:
|
||||
# TODO: extract edited HTML file from agent workspace
|
||||
# temp_zip = runtime.copy_from(f'/workspace/{instance.instance_id}')
|
||||
# file_name = f'/workspace/{instance.instance_id}/index.html'
|
||||
# with zipfile.ZipFile(temp_zip, 'r') as zip_ref:
|
||||
# if file_name in zip_ref.namelist():
|
||||
# with zip_ref.open(file_name) as file:
|
||||
# file_content = file.read().decode('utf-8') # Decode bytes to string
|
||||
# else:
|
||||
# raise FileNotFoundError(f"'{file_name}' not found in the ZIP archive.")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_folder = REPO_DOWNLOAD_DIR + f'data/{instance.instance_id}/post/'
|
||||
shutil.copytree(src_folder, tmpdir, dirs_exist_ok=True)
|
||||
|
||||
# image = capture_screenshot(tmpdir)
|
||||
# if image is not None:
|
||||
# shutil.copy(os.path.join(tmpdir, 'final_screenshot.png'), REPO_DOWNLOAD_DIR)
|
||||
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series, metadata: EvalMetadata, reset_logger: bool = True
|
||||
):
|
||||
config = get_config(metadata)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
|
||||
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
# =============================================
|
||||
# build instruction
|
||||
# =============================================
|
||||
|
||||
# Prepare instruction
|
||||
instruction = (
|
||||
f"Modify the HTML/CSS according to the following instruction:\n\n"
|
||||
f"{instance['changes']}\n\n"
|
||||
)
|
||||
instruction += (
|
||||
'IMPORTANT: You should ONLY interact with the environment provided '
|
||||
'to you AND NEVER ASK FOR HUMAN HELP.\n'
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# create sandbox and run the agent
|
||||
# =============================================
|
||||
|
||||
runtime: Runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
try:
|
||||
initialize_runtime(runtime, instance=instance)
|
||||
|
||||
image_urls = pil_image_to_base64(instance['prev_image'])
|
||||
|
||||
action = MessageAction(content=instruction, image_urls=image_urls)
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=action,
|
||||
runtime=runtime,
|
||||
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
|
||||
)
|
||||
)
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# =============================================
|
||||
# result evaluation
|
||||
# =============================================
|
||||
|
||||
return_val = complete_runtime(runtime, instance)
|
||||
logger.info(f'Return value {return_val}')
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
# TODO: return EVAL output
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the evaluation."""
|
||||
# args = parse_args()
|
||||
args = parse_arguments()
|
||||
|
||||
logger.info(f"\n{'='*80}\nStarting VisualCodeBench Evaluation\n{'='*80}")
|
||||
logger.info(f'Agent: {args.agent_cls}')
|
||||
logger.info(f'Model: {args.llm_config}')
|
||||
logger.info(f'Max iterations: {args.max_iterations}')
|
||||
logger.info(f'Eval limit: {args.eval_n_limit}')
|
||||
logger.info(f'Num workers: {args.eval_num_workers}\n')
|
||||
logger.info(f'Eval output: {args.eval_output_dir}\n')
|
||||
|
||||
# Step 1: Download the entire repository once
|
||||
logger.info('Downloading repository...')
|
||||
download_repository()
|
||||
|
||||
# Step 2: Load Dataset
|
||||
logger.info('Loading dataset...')
|
||||
dataset = load_dataset(REPO_DOWNLOAD_DIR)
|
||||
|
||||
# Step 3: Prepare dataset
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
if llm_config is None:
|
||||
logger.error(f'Could not find LLM config: {args.llm_config}')
|
||||
raise ValueError(f'Could not find LLM config: {args.llm_config}')
|
||||
|
||||
metadata = make_metadata(
|
||||
llm_config,
|
||||
'VisualCodeBench',
|
||||
args.agent_cls,
|
||||
args.max_iterations,
|
||||
args.eval_note,
|
||||
'evaluation/output/',
|
||||
)
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
dataset = prepare_visualcodebench(dataset)
|
||||
instances = prepare_dataset(dataset, output_file, eval_n_limit=args.eval_n_limit)
|
||||
|
||||
# Step 4: Run eval
|
||||
run_evaluation(
|
||||
instances, metadata, output_file, args.eval_num_workers, process_instance
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
46
evaluation/benchmarks/visualcodebench/scripts/run_infer.sh
Executable file
46
evaluation/benchmarks/visualcodebench/scripts/run_infer.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
source "evaluation/utils/version_control.sh"
|
||||
|
||||
# Check if required arguments are provided
|
||||
if [ "$#" -lt 4 ]; then
|
||||
echo "Usage: $0 [model_config] [commit_hash] [agent_cls] [eval_limit] [num_workers]"
|
||||
echo "Example: $0 llm.eval_gpt_4o_mini HEAD CodeActAgent 5 1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODEL_CONFIG=$1
|
||||
COMMIT_HASH=$2
|
||||
AGENT_CLS=$3
|
||||
EVAL_LIMIT=$4
|
||||
NUM_WORKERS=${5:-1} # Default to 1 worker if not specified
|
||||
|
||||
# Checkout the specified commit
|
||||
checkout_eval_branch
|
||||
|
||||
if [ -z "$AGENT" ]; then
|
||||
echo "Agent not specified, use default CodeActAgent"
|
||||
AGENT="CodeActAgent"
|
||||
fi
|
||||
|
||||
get_openhands_version
|
||||
|
||||
echo "AGENT: $AGENT"
|
||||
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
|
||||
echo "MODEL_CONFIG: $MODEL_CONFIG"
|
||||
|
||||
COMMAND="export PYTHONPATH=evaluation/benchmarks/visualcodebench:\$PYTHONPATH && poetry run python evaluation/benchmarks/visualcodebench/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 5 \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $OPENHANDS_VERSION" \
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
167
evaluation/benchmarks/visualcodebench/server.py
Normal file
167
evaluation/benchmarks/visualcodebench/server.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import http
|
||||
import os
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageChops
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def get_free_port():
|
||||
"""Find a free port to run the HTTP server."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def start_http_server(tmpdir):
|
||||
port = get_free_port()
|
||||
|
||||
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def translate_path(self, path):
|
||||
# Serve files from the specified directory instead of the current working directory
|
||||
path = super().translate_path(path)
|
||||
relative_path = os.path.relpath(path, os.getcwd())
|
||||
return os.path.join(tmpdir, relative_path)
|
||||
|
||||
handler = CustomHTTPRequestHandler
|
||||
server = socketserver.TCPServer(('', port), handler)
|
||||
return server, port
|
||||
|
||||
|
||||
def capture_screenshot(tmpdir):
|
||||
server, port = start_http_server(tmpdir)
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
time.sleep(10)
|
||||
|
||||
image = None
|
||||
try:
|
||||
server_url = f'http://localhost:{port}/'
|
||||
|
||||
if not is_server_reachable(server_url):
|
||||
raise RuntimeError(f'Server not reachable at {server_url}')
|
||||
|
||||
screenshot_path = os.path.join(tmpdir, 'final_screenshot.png')
|
||||
capture_screenshot_playwright(server_url, screenshot_path)
|
||||
image = Image.open(screenshot_path)
|
||||
image.load()
|
||||
finally:
|
||||
# Shut down the server and clean up
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def is_server_reachable(url):
|
||||
"""
|
||||
Check if the local server is reachable.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=5) # Set a 5-second timeout
|
||||
if response.status_code == 200:
|
||||
logger.info(f'Server is reachable at {url}')
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f'Server responded with status code {response.status_code} at {url}'
|
||||
)
|
||||
return False
|
||||
except requests.ConnectionError as e:
|
||||
logger.error(f'Failed to connect to server at {url}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def capture_screenshot_playwright(url, screenshot_path):
|
||||
"""Capture a screenshot of the given URL using Playwright."""
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
logger.info('Launching browser...')
|
||||
browser = p.chromium.launch(timeout=10000) # 10 seconds for browser launch
|
||||
|
||||
logger.info('Creating a new page...')
|
||||
page = browser.new_page()
|
||||
|
||||
logger.info(f'Navigating to URL: {url}')
|
||||
try:
|
||||
page.goto(url, timeout=60 * 1000) # Set timeout to 5 seconds
|
||||
logger.info('Page navigation completed.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Page navigation timed out. {e}. Continuing...')
|
||||
|
||||
logger.info('Waiting for network to be idle...')
|
||||
try:
|
||||
page.wait_for_load_state(
|
||||
'networkidle', timeout=60 * 1000
|
||||
) # Set timeout to 5 seconds
|
||||
logger.info('Page load state reached.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Page load state timed out. {e}. Continuing...')
|
||||
|
||||
logger.info('Capturing screenshot...')
|
||||
page.screenshot(
|
||||
path=screenshot_path, full_page=True
|
||||
) # Capture full page screenshot
|
||||
|
||||
logger.info(f'Screenshot saved to {screenshot_path}')
|
||||
browser.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Error capturing screenshot with Playwright: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def evaluate(task, screenshot_path):
|
||||
"""Compare generated screenshot with post_image using CLIP score."""
|
||||
try:
|
||||
import torch
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
# Load CLIP model and processor
|
||||
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
|
||||
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
|
||||
|
||||
# Load images
|
||||
post_image = Image.open(BytesIO(task['post_image']))
|
||||
generated_img = Image.open(screenshot_path)
|
||||
|
||||
# Process images
|
||||
inputs = processor(
|
||||
images=[post_image, generated_img], return_tensors='pt', padding=True
|
||||
)
|
||||
|
||||
# Get image features
|
||||
image_features = model.get_image_features(**inputs)
|
||||
|
||||
# Calculate cosine similarity
|
||||
similarity = torch.nn.functional.cosine_similarity(
|
||||
image_features[0].unsqueeze(0), image_features[1].unsqueeze(0)
|
||||
).item()
|
||||
|
||||
logger.info(f'CLIP similarity score: {similarity}')
|
||||
|
||||
return similarity > 0.95 # Consider it a match if similarity > 95%
|
||||
except Exception as e:
|
||||
logger.error(f'Error in CLIP evaluation: {e}')
|
||||
# Fallback to pixel comparison if CLIP fails
|
||||
try:
|
||||
post_image = Image.open(BytesIO(task['post_image']))
|
||||
generated_img = Image.open(screenshot_path)
|
||||
|
||||
# Compare images directly without converting to bytes
|
||||
diff = ImageChops.difference(generated_img, post_image)
|
||||
logger.info(
|
||||
f"Pixel difference analysis: {'No difference' if not diff.getbbox() else 'Differences found'}"
|
||||
)
|
||||
return not diff.getbbox()
|
||||
except Exception as ex:
|
||||
logger.error(f'Error in fallback evaluation: {ex}')
|
||||
return False
|
||||
50
evaluation/benchmarks/visualwebarena/README.md
Normal file
50
evaluation/benchmarks/visualwebarena/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# VisualWebArena Evaluation with OpenHands Browsing Agents
|
||||
|
||||
This folder contains evaluation for [VisualWebArena](https://github.com/web-arena-x/visualwebarena) benchmark, powered by [BrowserGym](https://github.com/ServiceNow/BrowserGym) for easy evaluation of how well an agent capable of browsing can perform on realistic web browsing tasks.
|
||||
|
||||
## Setup Environment and LLM Configuration
|
||||
|
||||
Please follow instruction [here](../../README.md#setup) to setup your local development environment and LLM.
|
||||
|
||||
## Setup VisualWebArena Environment
|
||||
|
||||
VisualWebArena requires you to set up websites containing pre-populated content that is accessible via URL to the machine running the OpenHands agents.
|
||||
Follow [this document](https://github.com/web-arena-x/visualwebarena/blob/main/environment_docker/README.md) to set up your own VisualWebArena environment through local servers or AWS EC2 instances.
|
||||
Take note of the base URL (`$VISUALWEBARENA_BASE_URL`) of the machine where the environment is installed.
|
||||
|
||||
## Test if your environment works
|
||||
|
||||
Access with browser the above VisualWebArena website URLs and see if they load correctly.
|
||||
If you cannot access the website, make sure the firewall allows public access of the aforementioned ports on your server
|
||||
Check the network security policy if you are using an AWS machine.
|
||||
Follow the VisualWebArena environment setup guide carefully, and make sure the URL fields are populated with the correct base URL of your server.
|
||||
|
||||
## Run Evaluation
|
||||
|
||||
```bash
|
||||
export VISUALWEBARENA_BASE_URL=<YOUR_SERVER_URL_HERE>
|
||||
export OPENAI_API_KEY="yourkey" # this OpenAI API key is required for some visualWebArena validators that utilize LLMs
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1/" # base URL for OpenAI model used for VisualWebArena evaluation
|
||||
bash evaluation/benchmarks/visualwebarena/scripts/run_infer.sh llm.claude HEAD VisualBrowsingAgent
|
||||
```
|
||||
|
||||
Results will be in `evaluation/evaluation_outputs/outputs/visualwebarena/`
|
||||
|
||||
To calculate the success rate, run:
|
||||
|
||||
```sh
|
||||
poetry run python evaluation/benchmarks/visualwebarena/get_success_rate.py evaluation/evaluation_outputs/outputs/visualwebarena/SOME_AGENT/EXP_NAME/output.jsonl
|
||||
```
|
||||
|
||||
## Submit your evaluation results
|
||||
|
||||
You can start your own fork of [our huggingface evaluation outputs](https://huggingface.co/spaces/OpenHands/evaluation) and submit a PR of your evaluation results following the guide [here](https://huggingface.co/docs/hub/en/repositories-pull-requests-discussions#pull-requests-and-discussions).
|
||||
|
||||
## VisualBrowsingAgent V1.0 result
|
||||
|
||||
Tested on VisualBrowsingAgent V1.0
|
||||
|
||||
VisualWebArena, 910 tasks (high cost, single run due to fixed task), max step 15. Resolve rates are:
|
||||
|
||||
- GPT4o: 26.15%
|
||||
- Claude-3.5 Sonnet: 25.27%
|
||||
0
evaluation/benchmarks/visualwebarena/__init__.py
Normal file
0
evaluation/benchmarks/visualwebarena/__init__.py
Normal file
40
evaluation/benchmarks/visualwebarena/get_success_rate.py
Normal file
40
evaluation/benchmarks/visualwebarena/get_success_rate.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
|
||||
import gymnasium as gym
|
||||
|
||||
parser = argparse.ArgumentParser(description='Calculate average reward.')
|
||||
parser.add_argument('output_path', type=str, help='path to output.jsonl')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if __name__ == '__main__':
|
||||
env_ids = [
|
||||
id
|
||||
for id in gym.envs.registry.keys()
|
||||
if id.startswith('browsergym/visualwebarena')
|
||||
]
|
||||
total_num = len(env_ids)
|
||||
print('Total number of tasks: ', total_num)
|
||||
total_reward = 0
|
||||
total_cost = 0
|
||||
actual_num = 0
|
||||
with open(args.output_path, 'r') as f:
|
||||
for line in f:
|
||||
data = json.loads(line)
|
||||
actual_num += 1
|
||||
total_cost += data['metrics']['accumulated_cost']
|
||||
reward = data['test_result']['reward']
|
||||
if reward >= 0:
|
||||
total_reward += data['test_result']['reward']
|
||||
else:
|
||||
actual_num -= 1
|
||||
avg_reward = total_reward / total_num
|
||||
print('Total reward: ', total_reward)
|
||||
print('Success Rate: ', avg_reward)
|
||||
|
||||
avg_cost = total_cost / actual_num
|
||||
print('Avg Cost: ', avg_cost)
|
||||
print('Total Cost: ', total_cost)
|
||||
print('Actual number of tasks finished: ', actual_num)
|
||||
254
evaluation/benchmarks/visualwebarena/run_infer.py
Normal file
254
evaluation/benchmarks/visualwebarena/run_infer.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
|
||||
import gymnasium as gym
|
||||
import pandas as pd
|
||||
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import (
|
||||
BrowseInteractiveAction,
|
||||
CmdRunAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.observation import CmdOutputObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.browser.browser_env import (
|
||||
BROWSER_EVAL_GET_GOAL_ACTION,
|
||||
BROWSER_EVAL_GET_REWARDS_ACTION,
|
||||
)
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
SUPPORTED_AGENT_CLS = {'VisualBrowsingAgent'}
|
||||
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
|
||||
'VisualBrowsingAgent': 'Continue the task. IMPORTANT: do not talk to the user until you have finished the task',
|
||||
}
|
||||
|
||||
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
env_id: str,
|
||||
) -> AppConfig:
|
||||
base_url = os.environ.get('VISUALWEBARENA_BASE_URL', None)
|
||||
openai_api_key = os.environ.get('OPENAI_API_KEY', None)
|
||||
openai_base_url = os.environ.get('OPENAI_BASE_URL', None)
|
||||
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
|
||||
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
|
||||
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
browsergym_eval_env=env_id,
|
||||
runtime_startup_env_vars={
|
||||
'BASE_URL': base_url,
|
||||
'OPENAI_API_KEY': openai_api_key,
|
||||
'OPENAI_BASE_URL': openai_base_url,
|
||||
'VWA_CLASSIFIEDS': f'{base_url}:9980',
|
||||
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
|
||||
'VWA_SHOPPING': f'{base_url}:7770',
|
||||
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
|
||||
'VWA_REDDIT': f'{base_url}:9999',
|
||||
'VWA_GITLAB': f'{base_url}:8023',
|
||||
'VWA_WIKIPEDIA': f'{base_url}:8888',
|
||||
'VWA_HOMEPAGE': f'{base_url}:4399',
|
||||
},
|
||||
timeout=300,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
attach_to_existing=True,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
metadata.llm_config,
|
||||
metadata.eval_output_dir,
|
||||
env_id,
|
||||
)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def initialize_runtime(
|
||||
runtime: Runtime,
|
||||
) -> tuple[str, list]:
|
||||
"""Initialize the runtime for the agent.
|
||||
|
||||
This function is called before the runtime is used to run the agent.
|
||||
"""
|
||||
logger.info(f"{'-' * 50} BEGIN Runtime Initialization Fn {'-' * 50}")
|
||||
obs: CmdOutputObservation
|
||||
|
||||
# Set instance id
|
||||
action = CmdRunAction(command='mkdir -p /workspace')
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
assert obs.exit_code == 0
|
||||
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_GOAL_ACTION)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
goal = obs.content
|
||||
goal_image_urls = []
|
||||
if hasattr(obs, 'goal_image_urls'):
|
||||
goal_image_urls = obs.goal_image_urls
|
||||
logger.info(f"{'-' * 50} END Runtime Initialization Fn {'-' * 50}")
|
||||
return goal, goal_image_urls
|
||||
|
||||
|
||||
def complete_runtime(
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any]:
|
||||
"""Complete the runtime for the agent.
|
||||
|
||||
This function is called before the runtime is used to run the agent.
|
||||
If you need to do something in the sandbox to get the correctness metric after
|
||||
the agent has run, modify this function.
|
||||
"""
|
||||
logger.info(f"{'-' * 50} BEGIN Runtime Completion Fn {'-' * 50}")
|
||||
obs: CmdOutputObservation
|
||||
|
||||
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_REWARDS_ACTION)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
logger.info(f"{'-' * 50} END Runtime Completion Fn {'-' * 50}")
|
||||
return {
|
||||
'rewards': json.loads(obs.content),
|
||||
}
|
||||
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
):
|
||||
env_id = instance.instance_id
|
||||
|
||||
config = get_config(metadata, env_id)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
|
||||
reset_logger_for_multiprocessing(logger, env_id, log_dir)
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {env_id}.')
|
||||
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
task_str, goal_image_urls = initialize_runtime(runtime)
|
||||
initial_user_action = MessageAction(content=task_str, image_urls=goal_image_urls)
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=initial_user_action,
|
||||
runtime=runtime,
|
||||
)
|
||||
)
|
||||
# ======= Attempt to evaluate the agent's environment impact =======
|
||||
|
||||
# If you are working on some simpler benchmark that only evaluates the final model output (e.g., in a MessageAction)
|
||||
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
|
||||
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Instruction obtained from the first message from the USER
|
||||
instruction = ''
|
||||
for event in state.history:
|
||||
if isinstance(event, MessageAction):
|
||||
instruction = event.content
|
||||
break
|
||||
|
||||
try:
|
||||
return_val = complete_runtime(runtime)
|
||||
logger.info(f'Return value from complete_runtime: {return_val}')
|
||||
reward = max(return_val['rewards'])
|
||||
except Exception:
|
||||
reward = -1.0 # kept -1 to identify instances for which evaluation failed.
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
instance_id=env_id,
|
||||
instruction=instruction,
|
||||
metadata=metadata,
|
||||
history=histories,
|
||||
metrics=metrics,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
test_result={
|
||||
'reward': reward,
|
||||
},
|
||||
)
|
||||
runtime.close()
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_arguments()
|
||||
|
||||
dataset = pd.DataFrame(
|
||||
{
|
||||
'instance_id': [
|
||||
id
|
||||
for id in gym.envs.registry.keys()
|
||||
if id.startswith('browsergym/visualwebarena')
|
||||
]
|
||||
}
|
||||
)
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
metadata = make_metadata(
|
||||
llm_config,
|
||||
'visualwebarena',
|
||||
args.agent_cls,
|
||||
args.max_iterations,
|
||||
args.eval_note,
|
||||
args.eval_output_dir,
|
||||
)
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
instances = prepare_dataset(dataset, output_file, args.eval_n_limit)
|
||||
|
||||
run_evaluation(
|
||||
instances,
|
||||
metadata,
|
||||
output_file,
|
||||
args.eval_num_workers,
|
||||
process_instance,
|
||||
)
|
||||
48
evaluation/benchmarks/visualwebarena/scripts/run_infer.sh
Executable file
48
evaluation/benchmarks/visualwebarena/scripts/run_infer.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
source "evaluation/utils/version_control.sh"
|
||||
|
||||
# configure browsing agent
|
||||
export USE_NAV="true"
|
||||
export USE_CONCISE_ANSWER="true"
|
||||
|
||||
MODEL_CONFIG=$1
|
||||
COMMIT_HASH=$2
|
||||
AGENT=$3
|
||||
EVAL_LIMIT=$4
|
||||
NUM_WORKERS=$5
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
NUM_WORKERS=1
|
||||
echo "Number of workers not specified, use default $NUM_WORKERS"
|
||||
fi
|
||||
checkout_eval_branch
|
||||
|
||||
if [ -z "$AGENT" ]; then
|
||||
echo "Agent not specified, use default VisualBrowsingAgent"
|
||||
AGENT="VisualBrowsingAgent"
|
||||
fi
|
||||
|
||||
get_openhands_version
|
||||
|
||||
echo "AGENT: $AGENT"
|
||||
echo "AGENT_VERSION: $OPENHANDS_VERSION"
|
||||
echo "MODEL_CONFIG: $MODEL_CONFIG"
|
||||
|
||||
EVAL_NOTE="${OPENHANDS_VERSION}"
|
||||
|
||||
COMMAND="poetry run python evaluation/benchmarks/visualwebarena/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 15 \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $EVAL_NOTE"
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
@@ -78,7 +78,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.use_microagents = False
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -8,13 +8,15 @@ from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestRes
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from evaluation.utils.shared import (
|
||||
codeact_user_response as fake_user_response,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
@@ -31,7 +33,9 @@ from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
FAKE_RESPONSES = {
|
||||
'CodeActAgent': codeact_user_response,
|
||||
'CodeActAgent': fake_user_response,
|
||||
'DelegatorAgent': fake_user_response,
|
||||
'VisualBrowsingAgent': fake_user_response,
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +223,7 @@ if __name__ == '__main__':
|
||||
|
||||
df = pd.read_json(output_file, lines=True, orient='records')
|
||||
|
||||
# record success and reason for failure for the final report
|
||||
# record success and reason
|
||||
df['success'] = df['test_result'].apply(lambda x: x['success'])
|
||||
df['reason'] = df['test_result'].apply(lambda x: x['reason'])
|
||||
logger.info('-' * 100)
|
||||
@@ -234,15 +238,27 @@ if __name__ == '__main__':
|
||||
logger.info('-' * 100)
|
||||
|
||||
# record cost for each instance, with 3 decimal places
|
||||
df['cost'] = df['metrics'].apply(lambda x: round(x['accumulated_cost'], 3))
|
||||
# we sum up all the "costs" from the metrics array
|
||||
df['cost'] = df['metrics'].apply(
|
||||
lambda m: round(sum(c['cost'] for c in m['costs']), 3)
|
||||
if m and 'costs' in m
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# capture the top-level error if present, per instance
|
||||
df['error_message'] = df.get('error', None)
|
||||
|
||||
logger.info(f'Total cost: USD {df["cost"].sum():.2f}')
|
||||
|
||||
report_file = os.path.join(metadata.eval_output_dir, 'report.md')
|
||||
with open(report_file, 'w') as f:
|
||||
f.write(
|
||||
f'Success rate: {df["success"].mean():.2%} ({df["success"].sum()}/{len(df)})\n'
|
||||
f'Success rate: {df["success"].mean():.2%}'
|
||||
f' ({df["success"].sum()}/{len(df)})\n'
|
||||
)
|
||||
f.write(f'\nTotal cost: USD {df["cost"].sum():.2f}\n')
|
||||
f.write(
|
||||
df[['instance_id', 'success', 'reason', 'cost']].to_markdown(index=False)
|
||||
df[
|
||||
['instance_id', 'success', 'reason', 'cost', 'error_message']
|
||||
].to_markdown(index=False)
|
||||
)
|
||||
|
||||
@@ -7,8 +7,9 @@ MODEL_CONFIG=$1
|
||||
COMMIT_HASH=$2
|
||||
AGENT=$3
|
||||
EVAL_LIMIT=$4
|
||||
NUM_WORKERS=$5
|
||||
EVAL_IDS=$6
|
||||
MAX_ITERATIONS=$5
|
||||
NUM_WORKERS=$6
|
||||
EVAL_IDS=$7
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
NUM_WORKERS=1
|
||||
@@ -43,7 +44,7 @@ fi
|
||||
COMMAND="poetry run python evaluation/integration_tests/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 10 \
|
||||
--max-iterations ${MAX_ITERATIONS:-10} \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $EVAL_NOTE"
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import hashlib
|
||||
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
FileWriteAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentDelegateObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = 'Execute the python script /workspace/python_script.py with input "John" and "25" and tell me the secret number.'
|
||||
SECRET_NUMBER = int(hashlib.sha256(str(25).encode()).hexdigest()[:8], 16) % 1000
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
action = FileWriteAction(
|
||||
path='/workspace/python_script.py',
|
||||
content=(
|
||||
'name = input("Enter your name: "); age = input("Enter your age: "); '
|
||||
'import hashlib; secret = int(hashlib.sha256(str(age).encode()).hexdigest()[:8], 16) % 1000; '
|
||||
'print(f"Hello {name}, you are {age} years old. Tell you a secret number: {secret}")'
|
||||
),
|
||||
)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
observation = runtime.run_action(action)
|
||||
logger.info(observation, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# check if the license information is in any message
|
||||
message_actions = [
|
||||
event
|
||||
for event in histories
|
||||
if isinstance(
|
||||
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
|
||||
)
|
||||
]
|
||||
logger.info(f'Total message-like events: {len(message_actions)}')
|
||||
|
||||
for event in message_actions:
|
||||
try:
|
||||
if isinstance(event, AgentDelegateObservation):
|
||||
content = event.content
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
content = event.outputs.get('content', '')
|
||||
if event.thought:
|
||||
content += f'\n\n{event.thought}'
|
||||
elif isinstance(event, MessageAction):
|
||||
content = event.content
|
||||
else:
|
||||
logger.warning(f'Unexpected event type: {type(event)}')
|
||||
continue
|
||||
|
||||
if str(cls.SECRET_NUMBER) in content:
|
||||
return TestResult(success=True)
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing event: {e}')
|
||||
|
||||
logger.debug(
|
||||
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
|
||||
)
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
|
||||
)
|
||||
@@ -52,30 +52,6 @@ class EvalMetadata(BaseModel):
|
||||
details: dict[str, Any] | None = None
|
||||
condenser_config: CondenserConfig | None = None
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
dumped_dict = super().model_dump(*args, **kwargs)
|
||||
# avoid leaking sensitive information
|
||||
dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
|
||||
if hasattr(self.condenser_config, 'llm_config'):
|
||||
dumped_dict['condenser_config']['llm_config'] = (
|
||||
self.condenser_config.llm_config.to_safe_dict()
|
||||
)
|
||||
|
||||
return dumped_dict
|
||||
|
||||
def model_dump_json(self, *args, **kwargs):
|
||||
dumped = super().model_dump_json(*args, **kwargs)
|
||||
dumped_dict = json.loads(dumped)
|
||||
# avoid leaking sensitive information
|
||||
dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
|
||||
if hasattr(self.condenser_config, 'llm_config'):
|
||||
dumped_dict['condenser_config']['llm_config'] = (
|
||||
self.condenser_config.llm_config.to_safe_dict()
|
||||
)
|
||||
|
||||
logger.debug(f'Dumped metadata: {dumped_dict}')
|
||||
return json.dumps(dumped_dict)
|
||||
|
||||
|
||||
class EvalOutput(BaseModel):
|
||||
# NOTE: User-specified
|
||||
@@ -98,23 +74,6 @@ class EvalOutput(BaseModel):
|
||||
# Optionally save the input test instance
|
||||
instance: dict[str, Any] | None = None
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
dumped_dict = super().model_dump(*args, **kwargs)
|
||||
# Remove None values
|
||||
dumped_dict = {k: v for k, v in dumped_dict.items() if v is not None}
|
||||
# Apply custom serialization for metadata (to avoid leaking sensitive information)
|
||||
if self.metadata is not None:
|
||||
dumped_dict['metadata'] = self.metadata.model_dump()
|
||||
return dumped_dict
|
||||
|
||||
def model_dump_json(self, *args, **kwargs):
|
||||
dumped = super().model_dump_json(*args, **kwargs)
|
||||
dumped_dict = json.loads(dumped)
|
||||
# Apply custom serialization for metadata (to avoid leaking sensitive information)
|
||||
if 'metadata' in dumped_dict:
|
||||
dumped_dict['metadata'] = json.loads(self.metadata.model_dump_json())
|
||||
return json.dumps(dumped_dict)
|
||||
|
||||
|
||||
class EvalException(Exception):
|
||||
pass
|
||||
@@ -314,7 +273,7 @@ def update_progress(
|
||||
logger.info(
|
||||
f'Finished evaluation for instance {result.instance_id}: {str(result.test_result)[:300]}...\n'
|
||||
)
|
||||
output_fp.write(json.dumps(result.model_dump()) + '\n')
|
||||
output_fp.write(result.model_dump_json() + '\n')
|
||||
output_fp.flush()
|
||||
|
||||
|
||||
@@ -371,7 +330,6 @@ def _process_instance_wrapper(
|
||||
error = str(e)
|
||||
stacktrace = traceback.format_exc()
|
||||
if attempt == max_retries:
|
||||
logger.exception(e)
|
||||
msg = (
|
||||
'-' * 10
|
||||
+ '\n'
|
||||
@@ -395,19 +353,15 @@ def _process_instance_wrapper(
|
||||
+ '-' * 10
|
||||
+ '\n'
|
||||
)
|
||||
if isinstance(
|
||||
e,
|
||||
(
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeUnavailableError,
|
||||
AgentRuntimeNotFoundError,
|
||||
),
|
||||
):
|
||||
# e is likely an EvalException, so we can't directly infer it from type
|
||||
# but rather check if it's a fatal error
|
||||
# But it can also be AgentRuntime**Error (e.g., swe_bench/eval_infer.py)
|
||||
_error_str = type(e).__name__ + ': ' + str(e)
|
||||
if is_fatal_runtime_error(_error_str):
|
||||
runtime_failure_count += 1
|
||||
msg += f'Runtime disconnected error detected for instance {instance.instance_id}, runtime failure count: {runtime_failure_count}'
|
||||
msg += '\n' + '-' * 10 + '\n'
|
||||
logger.error(msg)
|
||||
if use_mp:
|
||||
print(msg) # use print to directly print to console
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
@@ -564,6 +518,7 @@ def is_fatal_evaluation_error(error: str | None) -> bool:
|
||||
AgentRuntimeNotReadyError,
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeNotFoundError,
|
||||
ConnectionError,
|
||||
]
|
||||
|
||||
if any(exception.__name__ in error for exception in FATAL_EXCEPTIONS):
|
||||
@@ -573,6 +528,24 @@ def is_fatal_evaluation_error(error: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_fatal_runtime_error(error: str | None) -> bool:
|
||||
if not error:
|
||||
return False
|
||||
|
||||
FATAL_RUNTIME_ERRORS = [
|
||||
AgentRuntimeTimeoutError,
|
||||
AgentRuntimeUnavailableError,
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeNotFoundError,
|
||||
]
|
||||
|
||||
if any(exception.__name__ in error for exception in FATAL_RUNTIME_ERRORS):
|
||||
logger.error(f'Fatal runtime error detected: {error}')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_metrics(state: State) -> dict[str, Any]:
|
||||
"""Extract metrics from the state."""
|
||||
metrics = state.metrics.get() if state.metrics else {}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual as object,
|
||||
...(actual as object),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
@@ -14,7 +13,7 @@ vi.mock("react-router", async () => {
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual as object,
|
||||
...(actual as object),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
@@ -28,7 +27,6 @@ import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { BrowserPanel } from "#/components/features/browser/browser";
|
||||
|
||||
|
||||
describe("Browser", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -39,7 +37,6 @@ describe("Browser", () => {
|
||||
browser: {
|
||||
url: "https://example.com",
|
||||
screenshotSrc: "",
|
||||
updateCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -55,7 +52,6 @@ describe("Browser", () => {
|
||||
url: "https://example.com",
|
||||
screenshotSrc:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
|
||||
updateCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,36 +2,42 @@ import { describe, expect, it } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
|
||||
import { vi } from 'vitest';
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual('react-i18next');
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key:string) => key,
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
language: 'en',
|
||||
language: "en",
|
||||
exists: () => true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe("ExpandableMessage", () => {
|
||||
it("should render with neutral border for non-action messages", () => {
|
||||
renderWithProviders(<ExpandableMessage message="Hello" type="thought" />);
|
||||
const element = screen.getByText("Hello");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
const container = element.closest(
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with neutral border for error messages", () => {
|
||||
renderWithProviders(<ExpandableMessage message="Error occurred" type="error" />);
|
||||
renderWithProviders(
|
||||
<ExpandableMessage message="Error occurred" type="error" />,
|
||||
);
|
||||
const element = screen.getByText("Error occurred");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
const container = element.closest(
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-danger");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -43,10 +49,12 @@ describe("ExpandableMessage", () => {
|
||||
message="Command executed successfully"
|
||||
type="action"
|
||||
success={true}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
const container = element.closest(
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
const icon = screen.getByTestId("status-icon");
|
||||
expect(icon).toHaveClass("fill-success");
|
||||
@@ -59,10 +67,12 @@ describe("ExpandableMessage", () => {
|
||||
message="Command failed"
|
||||
type="action"
|
||||
success={false}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
const container = element.closest(
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
const icon = screen.getByTestId("status-icon");
|
||||
expect(icon).toHaveClass("fill-danger");
|
||||
@@ -74,10 +84,12 @@ describe("ExpandableMessage", () => {
|
||||
id="OBSERVATION_MESSAGE$RUN"
|
||||
message="Running command"
|
||||
type="action"
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
const container = element.closest(
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@ import { ContextMenuListItem } from "#/components/features/context-menu/context-
|
||||
|
||||
describe("ContextMenuListItem", () => {
|
||||
it("should render the component with the children", () => {
|
||||
render(<ContextMenuListItem onClick={vi.fn}>Test</ContextMenuListItem>);
|
||||
const onClickMock = vi.fn();
|
||||
render(
|
||||
<ContextMenuListItem onClick={onClickMock}>Test</ContextMenuListItem>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
QueryClientProvider,
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { clickOnEditButton } from "./utils";
|
||||
import { queryClientConfig } from "#/query-client-config";
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
@@ -231,4 +233,47 @@ describe("ConversationPanel", () => {
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should refetch data on rerenders", async () => {
|
||||
// We need to simulate the toggling of the component to test the refetching
|
||||
function PanelWithToggle() {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen((prev) => !prev)}>
|
||||
Toggle
|
||||
</button>
|
||||
{isOpen && <ConversationPanel onClose={onCloseMock} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MyRouterStub = createRoutesStub([
|
||||
{
|
||||
Component: PanelWithToggle,
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
render(<MyRouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={new QueryClient(queryClientConfig)}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getUserConversationsSpy).toHaveBeenCalledOnce());
|
||||
|
||||
const button = screen.getByText("Toggle");
|
||||
await userEvent.click(button);
|
||||
await userEvent.click(button);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getUserConversationsSpy).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
|
||||
|
||||
// These tests will now fail because the conversation panel is rendered through a portal
|
||||
// and technically not a child of the Sidebar component.
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -128,7 +131,7 @@ describe("Sidebar", () => {
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput =
|
||||
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_OPTIONAL/i);
|
||||
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i);
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton =
|
||||
@@ -152,11 +155,13 @@ describe("Sidebar", () => {
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
|
||||
// Click the advanced options switch to show the API key input
|
||||
const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch");
|
||||
const advancedOptionsSwitch = within(settingsModal).getByTestId(
|
||||
"advanced-option-switch",
|
||||
);
|
||||
await user.click(advancedOptionsSwitch);
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
|
||||
await user.type(apiKeyInput, "SET");
|
||||
await user.type(apiKeyInput, "**********");
|
||||
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FeedbackActions } from "#/components/features/feedback/feedback-actions";
|
||||
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
|
||||
|
||||
describe("FeedbackActions", () => {
|
||||
describe("TrajectoryActions", () => {
|
||||
const user = userEvent.setup();
|
||||
const onPositiveFeedback = vi.fn();
|
||||
const onNegativeFeedback = vi.fn();
|
||||
const onExportTrajectory = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -14,9 +15,10 @@ describe("FeedbackActions", () => {
|
||||
|
||||
it("should render correctly", () => {
|
||||
render(
|
||||
<FeedbackActions
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={onPositiveFeedback}
|
||||
onNegativeFeedback={onNegativeFeedback}
|
||||
onExportTrajectory={onExportTrajectory}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -27,9 +29,10 @@ describe("FeedbackActions", () => {
|
||||
|
||||
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
|
||||
render(
|
||||
<FeedbackActions
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={onPositiveFeedback}
|
||||
onNegativeFeedback={onNegativeFeedback}
|
||||
onExportTrajectory={onExportTrajectory}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -41,9 +44,10 @@ describe("FeedbackActions", () => {
|
||||
|
||||
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
|
||||
render(
|
||||
<FeedbackActions
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={onPositiveFeedback}
|
||||
onNegativeFeedback={onNegativeFeedback}
|
||||
onExportTrajectory={onExportTrajectory}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -52,4 +56,19 @@ describe("FeedbackActions", () => {
|
||||
|
||||
expect(onNegativeFeedback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onExportTrajectory when negative feedback is clicked", async () => {
|
||||
render(
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={onPositiveFeedback}
|
||||
onNegativeFeedback={onNegativeFeedback}
|
||||
onExportTrajectory={onExportTrajectory}
|
||||
/>,
|
||||
);
|
||||
|
||||
const exportButton = screen.getByTestId("export-trajectory");
|
||||
await user.click(exportButton);
|
||||
|
||||
expect(onExportTrajectory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual as object,
|
||||
...(actual as object),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
@@ -60,7 +59,9 @@ describe("FeedbackForm", () => {
|
||||
renderWithProviders(
|
||||
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }),
|
||||
);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4,13 +4,21 @@ import { describe, it, vi, expect } from "vitest";
|
||||
import { BaseModal } from "#/components/shared/modals/base-modal/base-modal";
|
||||
|
||||
describe("BaseModal", () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
it("should render if the modal is open", () => {
|
||||
const { rerender } = render(
|
||||
<BaseModal isOpen={false} onOpenChange={vi.fn} title="Settings" />,
|
||||
<BaseModal
|
||||
isOpen={false}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
title="Settings"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<BaseModal title="Settings" onOpenChange={vi.fn} isOpen />);
|
||||
rerender(
|
||||
<BaseModal title="Settings" onOpenChange={onOpenChangeMock} isOpen />,
|
||||
);
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -18,7 +26,7 @@ describe("BaseModal", () => {
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
title="Settings"
|
||||
subtitle="Subtitle"
|
||||
/>,
|
||||
@@ -43,7 +51,7 @@ describe("BaseModal", () => {
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
title="Settings"
|
||||
actions={[primaryAction, secondaryAction]}
|
||||
/>,
|
||||
@@ -60,7 +68,6 @@ describe("BaseModal", () => {
|
||||
});
|
||||
|
||||
it("should close the modal after an action is performed", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
@@ -82,7 +89,7 @@ describe("BaseModal", () => {
|
||||
|
||||
it("should render children", () => {
|
||||
render(
|
||||
<BaseModal isOpen onOpenChange={vi.fn} title="Settings">
|
||||
<BaseModal isOpen onOpenChange={onOpenChangeMock} title="Settings">
|
||||
<div>Children</div>
|
||||
</BaseModal>,
|
||||
);
|
||||
@@ -93,7 +100,7 @@ describe("BaseModal", () => {
|
||||
const { rerender } = render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
title="Settings"
|
||||
actions={[
|
||||
{
|
||||
@@ -110,7 +117,7 @@ describe("BaseModal", () => {
|
||||
rerender(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
title="Settings"
|
||||
actions={[
|
||||
{
|
||||
@@ -126,7 +133,6 @@ describe("BaseModal", () => {
|
||||
});
|
||||
|
||||
it.skip("should not close if the backdrop or escape key is pressed", () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import store from "../src/store";
|
||||
import {
|
||||
setInitialQuery,
|
||||
clearInitialQuery,
|
||||
setInitialPrompt,
|
||||
clearInitialPrompt,
|
||||
} from "../src/state/initial-query-slice";
|
||||
|
||||
describe("Initial Query Behavior", () => {
|
||||
it("should clear initial query when clearInitialQuery is dispatched", () => {
|
||||
it("should clear initial query when clearInitialPrompt is dispatched", () => {
|
||||
// Set up initial query in the store
|
||||
store.dispatch(setInitialQuery("test query"));
|
||||
expect(store.getState().initialQuery.initialQuery).toBe("test query");
|
||||
store.dispatch(setInitialPrompt("test query"));
|
||||
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
|
||||
|
||||
// Clear the initial query
|
||||
store.dispatch(clearInitialQuery());
|
||||
store.dispatch(clearInitialPrompt());
|
||||
|
||||
// Verify initial query is cleared
|
||||
expect(store.getState().initialQuery.initialQuery).toBeNull();
|
||||
expect(store.getState().initialQuery.initialPrompt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
|
||||
20
frontend/__tests__/utils/test-config.tsx
Normal file
20
frontend/__tests__/utils/test-config.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { vi } from "vitest";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const setupTestConfig = () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "test-id",
|
||||
POSTHOG_CLIENT_KEY: "test-key",
|
||||
});
|
||||
};
|
||||
|
||||
export const setupSaasTestConfig = () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-id",
|
||||
POSTHOG_CLIENT_KEY: "test-key",
|
||||
});
|
||||
};
|
||||
1300
frontend/package-lock.json
generated
1300
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -9,24 +9,25 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@react-router/node": "^7.1.1",
|
||||
"@react-router/serve": "^7.1.1",
|
||||
"@react-types/shared": "^3.25.0",
|
||||
"@react-router/node": "^7.1.2",
|
||||
"@react-router/serve": "^7.1.2",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@tanstack/react-query": "^5.63.0",
|
||||
"@tanstack/react-query": "^5.64.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.0.1",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.21",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.205.0",
|
||||
"posthog-js": "^1.207.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -35,7 +36,7 @@
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-router": "^7.1.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -77,21 +78,22 @@
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@react-router/dev": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.64.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vitest/coverage-v8": "^3.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -100,20 +102,20 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.3.0",
|
||||
"lint-staged": "^15.4.1",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^3.0.2"
|
||||
},
|
||||
"packageManager": "npm@10.5.0",
|
||||
"volta": {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
|
||||
// Axios interceptor to handle token refresh
|
||||
const setupAxiosInterceptors = (
|
||||
appMode: string,
|
||||
refreshToken: () => Promise<boolean>,
|
||||
logout: () => void,
|
||||
) => {
|
||||
@@ -74,18 +75,21 @@ const setupAxiosInterceptors = (
|
||||
!originalRequest._retry // Prevent infinite retry loops
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
return await github(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
if (appMode === "saas") {
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
return await github(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
GetTrajectoryResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -243,10 +244,14 @@ class OpenHands {
|
||||
static async createConversation(
|
||||
githubToken?: string,
|
||||
selectedRepository?: string,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
github_token: githubToken,
|
||||
selected_repository: selectedRepository,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
@@ -354,6 +359,15 @@ class OpenHands {
|
||||
|
||||
return response.data.items;
|
||||
}
|
||||
|
||||
static async getTrajectory(
|
||||
conversationId: string,
|
||||
): Promise<GetTrajectoryResponse> {
|
||||
const { data } = await openHands.get<GetTrajectoryResponse>(
|
||||
`/api/conversations/${conversationId}/trajectory`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface GetVSCodeUrlResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetTrajectoryResponse {
|
||||
trajectory: unknown[] | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticateResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AGENT_STATUS_MAP: {
|
||||
},
|
||||
[AgentState.AWAITING_USER_INPUT]: {
|
||||
message: I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE,
|
||||
indicator: IndicatorColor.ORANGE,
|
||||
indicator: IndicatorColor.BLUE,
|
||||
},
|
||||
[AgentState.PAUSED]: {
|
||||
message: I18nKey.CHAT_INTERFACE$AGENT_PAUSED_MESSAGE,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams } from "react-router";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { FeedbackActions } from "../feedback/feedback-actions";
|
||||
import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { addUserMessage } from "#/state/chat-slice";
|
||||
@@ -19,6 +21,8 @@ import { ActionSuggestions } from "./action-suggestions";
|
||||
import { ContinueButton } from "#/components/shared/buttons/continue-button";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { downloadTrajectory } from "#/utils/download-files";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -47,6 +51,8 @@ export function ChatInterface() {
|
||||
const { selectedRepository, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const params = useParams();
|
||||
const { mutate: getTrajectory } = useGetTrajectory();
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
if (messages.length === 0) {
|
||||
@@ -90,6 +96,25 @@ export function ChatInterface() {
|
||||
setFeedbackPolarity(polarity);
|
||||
};
|
||||
|
||||
const onClickExportTrajectoryButton = () => {
|
||||
if (!params.conversationId) {
|
||||
toast.error("ConversationId unknown, cannot download trajectory");
|
||||
return;
|
||||
}
|
||||
|
||||
getTrajectory(params.conversationId, {
|
||||
onSuccess: async (data) => {
|
||||
await downloadTrajectory(
|
||||
params.conversationId ?? "unknown",
|
||||
data.trajectory,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isWaitingForUserInput =
|
||||
curAgentState === AgentState.AWAITING_USER_INPUT ||
|
||||
curAgentState === AgentState.FINISHED;
|
||||
@@ -129,13 +154,14 @@ export function ChatInterface() {
|
||||
|
||||
<div className="flex flex-col gap-[6px] px-4 pb-4">
|
||||
<div className="flex justify-between relative">
|
||||
<FeedbackActions
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
}
|
||||
onNegativeFeedback={() =>
|
||||
onClickShareFeedbackActionButton("negative")
|
||||
}
|
||||
onExportTrajectory={() => onClickExportTrajectoryButton()}
|
||||
/>
|
||||
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
|
||||
|
||||
@@ -12,15 +12,22 @@ interface MessagesProps {
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) =>
|
||||
messages.map((message, index) => {
|
||||
const shouldShowConfirmationButtons =
|
||||
messages.length - 1 === index &&
|
||||
message.sender === "assistant" &&
|
||||
isAwaitingUserConfirmation;
|
||||
|
||||
if (message.type === "error" || message.type === "action") {
|
||||
return (
|
||||
<ExpandableMessage
|
||||
key={index}
|
||||
type={message.type}
|
||||
id={message.translationID}
|
||||
message={message.content}
|
||||
success={message.success}
|
||||
/>
|
||||
<div key={index}>
|
||||
<ExpandableMessage
|
||||
type={message.type}
|
||||
id={message.translationID}
|
||||
message={message.content}
|
||||
success={message.success}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,9 +40,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
{message.imageUrls && message.imageUrls.length > 0 && (
|
||||
<ImageCarousel size="small" images={message.imageUrls} />
|
||||
)}
|
||||
{messages.length - 1 === index &&
|
||||
message.sender === "assistant" &&
|
||||
isAwaitingUserConfirmation && <ConfirmationButtons />}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -5,11 +5,16 @@ import toast from "react-hot-toast";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { AGENT_STATUS_MAP } from "../../agent-status-map.constant";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
|
||||
export function AgentStatusBar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||
const { status } = useWsClient();
|
||||
|
||||
const [statusMessage, setStatusMessage] = React.useState<string>("");
|
||||
|
||||
@@ -37,7 +42,11 @@ export function AgentStatusBar() {
|
||||
}, [curStatusMessage.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
if (status === WsClientProviderStatus.DISCONNECTED) {
|
||||
setStatusMessage("Connecting...");
|
||||
} else {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
}
|
||||
}, [curAgentState]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
@@ -27,7 +28,13 @@ export function Sidebar() {
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const { isUpToDate: settingsAreUpToDate, settings } = useCurrentSettings();
|
||||
const {
|
||||
data: settings,
|
||||
isError: settingsIsError,
|
||||
isSuccess: settingsSuccessfulyFetched,
|
||||
} = useSettings();
|
||||
|
||||
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
@@ -103,12 +110,13 @@ export function Sidebar() {
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{showSettingsModal && settings && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && settingsSuccessfulyFetched && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
|
||||
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
|
||||
import { FeedbackActionButton } from "#/components/shared/buttons/feedback-action-button";
|
||||
import ExportIcon from "#/icons/export.svg?react";
|
||||
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
|
||||
|
||||
interface FeedbackActionsProps {
|
||||
interface TrajectoryActionsProps {
|
||||
onPositiveFeedback: () => void;
|
||||
onNegativeFeedback: () => void;
|
||||
onExportTrajectory: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackActions({
|
||||
export function TrajectoryActions({
|
||||
onPositiveFeedback,
|
||||
onNegativeFeedback,
|
||||
}: FeedbackActionsProps) {
|
||||
onExportTrajectory,
|
||||
}: TrajectoryActionsProps) {
|
||||
return (
|
||||
<div data-testid="feedback-actions" className="flex gap-1">
|
||||
<FeedbackActionButton
|
||||
<TrajectoryActionButton
|
||||
testId="positive-feedback"
|
||||
onClick={onPositiveFeedback}
|
||||
icon={<ThumbsUpIcon width={15} height={15} />}
|
||||
/>
|
||||
<FeedbackActionButton
|
||||
<TrajectoryActionButton
|
||||
testId="negative-feedback"
|
||||
onClick={onNegativeFeedback}
|
||||
icon={<ThumbDownIcon width={15} height={15} />}
|
||||
/>
|
||||
<TrajectoryActionButton
|
||||
testId="export-trajectory"
|
||||
onClick={onExportTrajectory}
|
||||
icon={<ExportIcon width={15} height={15} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
interface FeedbackActionButtonProps {
|
||||
interface TrajectoryActionButtonProps {
|
||||
testId?: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeedbackActionButton({
|
||||
export function TrajectoryActionButton({
|
||||
testId,
|
||||
onClick,
|
||||
icon,
|
||||
}: FeedbackActionButtonProps) {
|
||||
}: TrajectoryActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -91,7 +91,7 @@ export function AccountSettingsForm({
|
||||
<>
|
||||
<CustomInput
|
||||
name="ghToken"
|
||||
label={t(I18nKey.GITHUB$TOKEN_OPTIONAL)}
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,10 @@ export function RuntimeSizeSelector({
|
||||
id="runtime-size"
|
||||
name="runtime-size"
|
||||
defaultSelectedKeys={[String(defaultValue || 1)]}
|
||||
selectedKeys={[String(defaultValue || 1)]}
|
||||
isDisabled={isDisabled}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
aria-label={t(I18nKey.SETTINGS_FORM$RUNTIME_SIZE_LABEL)}
|
||||
classNames={{
|
||||
trigger: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
|
||||
|
||||
@@ -171,7 +171,7 @@ export function SettingsForm({
|
||||
|
||||
<APIKeyInput
|
||||
isDisabled={!!disabled}
|
||||
isSet={settings.LLM_API_KEY === "SET"}
|
||||
isSet={settings.LLM_API_KEY === "**********"}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
|
||||
@@ -87,7 +87,12 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setUserId(userId);
|
||||
setupGithubAxiosInterceptors(refreshToken, logout);
|
||||
const setupIntercepter = async () => {
|
||||
const config = await OpenHands.getConfig();
|
||||
setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
|
||||
};
|
||||
|
||||
setupIntercepter();
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
if (updatedSettings.LLM_API_KEY === "SET") {
|
||||
if (updatedSettings.LLM_API_KEY === "**********") {
|
||||
delete updatedSettings.LLM_API_KEY;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
|
||||
!!val && typeof val === "object";
|
||||
const isString = (val: unknown): val is string => typeof val === "string";
|
||||
if (isObject(data) && "message" in data && isString(data.message)) {
|
||||
if (data.message === "websocket error") {
|
||||
return;
|
||||
}
|
||||
let msgId: string | undefined;
|
||||
if (
|
||||
"data" in data &&
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user