mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
44 Commits
0.14.0
...
fix-messag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352979578f | ||
|
|
05ca829723 | ||
|
|
07b96cc8c9 | ||
|
|
3a65b7b07d | ||
|
|
5c83698524 | ||
|
|
cde7ce49be | ||
|
|
24a83eb52d | ||
|
|
2a78b3323b | ||
|
|
a3977621ed | ||
|
|
018080aae0 | ||
|
|
302e41d7bb | ||
|
|
3c61a9521b | ||
|
|
c9ed9b166b | ||
|
|
e052c25572 | ||
|
|
f0ca45c59e | ||
|
|
7f5022c8fe | ||
|
|
de07fcfddc | ||
|
|
ff84a3eede | ||
|
|
1f723293db | ||
|
|
2c580387c5 | ||
|
|
ca64c69b4a | ||
|
|
a531413d86 | ||
|
|
422104c877 | ||
|
|
c75ca7d976 | ||
|
|
6b89386398 | ||
|
|
a87b8599eb | ||
|
|
de821718fd | ||
|
|
088e895a3d | ||
|
|
104f52bcdd | ||
|
|
97f3249205 | ||
|
|
9d47ddba38 | ||
|
|
f7652bd558 | ||
|
|
2b7932b46c | ||
|
|
7074e45ec3 | ||
|
|
a679fcc3b5 | ||
|
|
8b1d5f5a3b | ||
|
|
9882b62777 | ||
|
|
b49bdb9d85 | ||
|
|
00ffc33d1b | ||
|
|
1acb66c2b3 | ||
|
|
5b3db1bd33 | ||
|
|
bdc4513937 | ||
|
|
ffc4d32440 | ||
|
|
9cd248d475 |
65
.github/workflows/lint-fix.yml
vendored
Normal file
65
.github/workflows/lint-fix.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Lint Fix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
lint-fix:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix linting issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Frontend lint fixes
|
||||
- name: Install Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
- name: Fix frontend lint issues
|
||||
run: |
|
||||
cd frontend
|
||||
npm run lint:fix
|
||||
|
||||
# Python lint fixes
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Fix python lint issues
|
||||
run: |
|
||||
pre-commit run trailing-whitespace --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run pyproject-fmt --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run ruff --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run ruff-format --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
# Commit and push changes if any
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
- name: Commit and push if there are changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
git config --local user.email "openhands@all-hands.dev"
|
||||
git config --local user.name "OpenHands Bot"
|
||||
git add -A
|
||||
git commit -m "🤖 Auto-fix linting issues"
|
||||
git push
|
||||
22
.github/workflows/openhands-resolver.yml
vendored
22
.github/workflows/openhands-resolver.yml
vendored
@@ -40,7 +40,6 @@ permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
|
||||
auto-fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
@@ -76,7 +75,18 @@ jobs:
|
||||
cat requirements.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
if: github.event.label.name != 'fix-me-experimental'
|
||||
if: |
|
||||
!(
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
startsWith(github.event.comment.body, inputs.macro || '@openhands-agent-exp')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
startsWith(github.event.review.body, inputs.macro || '@openhands-agent-exp')
|
||||
)
|
||||
)
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
|
||||
@@ -140,7 +150,11 @@ jobs:
|
||||
|
||||
- name: Install OpenHands
|
||||
run: |
|
||||
if [ "${{ github.event.label.name }}" == "fix-me-experimental" ]; then
|
||||
if [[ "${{ github.event.label.name }}" == "fix-me-experimental" ]] ||
|
||||
([[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]] &&
|
||||
[[ "${{ github.event.comment.body }}" == "@openhands-agent-exp"* ]]) ||
|
||||
([[ "${{ github.event_name }}" == "pull_request_review" ]] &&
|
||||
[[ "${{ github.event.review.body }}" == "@openhands-agent-exp"* ]]); then
|
||||
python -m pip install --upgrade pip
|
||||
pip install git+https://github.com/all-hands-ai/openhands.git
|
||||
else
|
||||
@@ -181,6 +195,7 @@ jobs:
|
||||
retention-days: 30 # Keep the artifact for 30 days
|
||||
|
||||
- name: Create draft PR or push branch
|
||||
if: always() # Create PR or branch even if the previous steps fail
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
@@ -204,6 +219,7 @@ jobs:
|
||||
|
||||
- name: Comment on issue
|
||||
uses: actions/github-script@v7
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
||||
43
COMMUNITY.md
Normal file
43
COMMUNITY.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 🙌 The OpenHands Community
|
||||
|
||||
The OpenHands community is built around the belief that (1) AI and AI agents are going to fundamentally change the way
|
||||
we build software, and (2) if this is true, we should do everything we can to make sure that the benefits provided by
|
||||
such powerful technology are accessible to everyone.
|
||||
|
||||
If this resonates with you, we'd love to have you join us in our quest!
|
||||
|
||||
## 🤝 How to Join
|
||||
|
||||
Check out our [How to Join the Community section.](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-how-to-join-the-community)
|
||||
|
||||
## 💪 Becoming a Contributor
|
||||
|
||||
We welcome contributions from everyone! Whether you're a developer, a researcher, or simply enthusiastic about advancing
|
||||
the field of software engineering with AI, there are many ways to get involved:
|
||||
|
||||
- **Code Contributions:** Help us develop new core functionality, improve our agents, improve the frontend and other
|
||||
interfaces, or anything else that would help make OpenHands better.
|
||||
- **Research and Evaluation:** Contribute to our understanding of LLMs in software engineering, participate in
|
||||
evaluating the models, or suggest improvements.
|
||||
- **Feedback and Testing:** Use the OpenHands toolset, report bugs, suggest features, or provide feedback on usability.
|
||||
|
||||
For details, please check [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We have a [Code of Conduct](./CODE_OF_CONDUCT.md) that we expect all contributors to adhere to.
|
||||
Long story short, we are aiming for an open, welcoming, diverse, inclusive, and healthy community.
|
||||
All contributors are expected to contribute to building this sort of community.
|
||||
|
||||
## 🛠️ Becoming a Maintainer
|
||||
|
||||
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining
|
||||
the maintainer team. The process for this is as follows:
|
||||
|
||||
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any
|
||||
maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
|
||||
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
|
||||
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
|
||||
|
||||
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking
|
||||
at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
@@ -54,7 +54,7 @@ The agent needs a place to run code and commands. When you run OpenHands on your
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
|
||||
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
|
||||
by implementing the [interface specified here](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/runtime.py).
|
||||
by implementing the [interface specified here](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/base.py).
|
||||
|
||||
#### Testing
|
||||
When you write code, it is also good to write tests. Please navigate to the `tests` folder to see existing test suites.
|
||||
@@ -92,3 +92,32 @@ You may also check out previous PRs in the [PR list](https://github.com/All-Hand
|
||||
|
||||
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
|
||||
please include a short message that we can add to our changelog.
|
||||
|
||||
## How to Make Effective Contributions
|
||||
|
||||
### Opening Issues
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/All-Hands-AI/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all PRs, with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
3. **Code Complexity**
|
||||
|
||||
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
|
||||
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
|
||||
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
|
||||
|
||||
@@ -38,7 +38,9 @@ make build
|
||||
```
|
||||
|
||||
### 3. Configuring the Language Model
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library. By default, we've chosen the mighty GPT-4 from OpenAI as our go-to model, but the world is your oyster! You can unleash the potential of Anthropic's suave Claude, the enigmatic Llama, or any other LM that piques your interest.
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
By default, we've chosen Claude Sonnet 3.5 as our go-to model, but the world is your oyster! You can unleash the
|
||||
potential of any other LM that piques your interest.
|
||||
|
||||
To configure the LM of your choice, run:
|
||||
|
||||
@@ -52,10 +54,7 @@ To configure the LM of your choice, run:
|
||||
Environment variables > config.toml variables > default variables
|
||||
|
||||
**Note on Alternative Models:**
|
||||
Some alternative models may prove more challenging to tame than others. Fear not, brave adventurer! We shall soon unveil LLM-specific documentation to guide you on your quest.
|
||||
And if you've already mastered the art of wielding a model other than OpenAI's GPT, we encourage you to share your setup instructions with us by creating instructions and adding it [to our documentation](https://github.com/All-Hands-AI/OpenHands/tree/main/docs/modules/usage/llms).
|
||||
|
||||
For a full list of the LM providers and models available, please consult the [litellm documentation](https://docs.litellm.ai/docs/providers).
|
||||
See [our documentation](https://docs.all-hands.dev/modules/usage/llms) for recommended models.
|
||||
|
||||
### 4. Running the application
|
||||
#### Option A: Run the Full Application
|
||||
@@ -98,9 +97,10 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`
|
||||
|
||||
### 9. Use existing Docker image
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
|
||||
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
|
||||
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.14-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
* Issues may be tagged with what it relates to (**backend**, **frontend**, **agent quality**, etc.)
|
||||
|
||||
## Severity
|
||||
* **Low**: Minor issues, single user report
|
||||
* **Medium**: Affecting multiple users
|
||||
* **Critical**: Affecting all users or potential security issues
|
||||
* **Low**: Minor issues or affecting single user.
|
||||
* **Medium**: Affecting multiple users.
|
||||
* **Critical**: Affecting all users or potential security issues.
|
||||
|
||||
## Effort
|
||||
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**)
|
||||
@@ -17,9 +17,9 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
* Issues with low implementation difficulty may be tagged with **good first issue**
|
||||
|
||||
## Not Enough Information
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear
|
||||
* If an issue is unclear and the author does not provide more information or respond to a request, the issue may be closed as **not planned** (Usually after a week)
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
|
||||
* If an issue is unclear and the author does not provide more information or respond to a request, the issue may be closed as **not planned** (Usually after a week).
|
||||
|
||||
## Multiple Requests/Fixes in One Issue
|
||||
* These issues will be narrowed down to one request/fix so the issue is more easily tracked and fixed
|
||||
* Issues may be broken down into multiple issues if required
|
||||
* These issues will be narrowed down to one request/fix so the issue is more easily tracked and fixed.
|
||||
* Issues may be broken down into multiple issues if required.
|
||||
|
||||
33
README.md
33
README.md
@@ -38,16 +38,16 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.14
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
@@ -61,7 +61,7 @@ works best, but you have [many options](https://docs.all-hands.dev/modules/usage
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes),
|
||||
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
|
||||
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://github.com/All-Hands-AI/OpenHands-resolver).
|
||||
or run it on tagged issues with [a github action](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
|
||||
|
||||
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
@@ -77,25 +77,16 @@ To learn more about the project, and for tips on using OpenHands,
|
||||
There you'll find resources on how to use different LLM providers,
|
||||
troubleshooting resources, and advanced configuration options.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
## 🤝 How to Join the Community
|
||||
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone.
|
||||
Whether you're a developer, a researcher, or simply enthusiastic about advancing the field of
|
||||
software engineering with AI, there are many ways to get involved:
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
|
||||
|
||||
- **Code Contributions:** Help us develop new agents, core functionality, the frontend and other interfaces, or sandboxing solutions.
|
||||
- **Research and Evaluation:** Contribute to our understanding of LLMs in software engineering, participate in evaluating the models, or suggest improvements.
|
||||
- **Feedback and Testing:** Use the OpenHands toolset, report bugs, suggest features, or provide feedback on usability.
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2tom0er4l-JeNUGHt_AxpEfIBstbLPiw) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
For details, please check [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## 🤖 Join Our Community
|
||||
|
||||
Whether you're a developer, a researcher, or simply enthusiastic about OpenHands, we'd love to have you in our community.
|
||||
Let's make software engineering better together!
|
||||
|
||||
- [Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2tom0er4l-JeNUGHt_AxpEfIBstbLPiw) - Here we talk about research, architecture, and future development.
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
|
||||
See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details on contributing in [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## 📈 Progress
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.14-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -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.13-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.14-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -14,4 +14,4 @@ Pour utiliser l'Action GitHub OpenHands dans le dépôt OpenHands, un mainteneur
|
||||
|
||||
## Installation de l'Action dans un nouveau dépôt
|
||||
|
||||
Pour installer l'Action GitHub OpenHands dans votre propre dépôt, suivez les [instructions dans le dépôt OpenHands Resolver](https://github.com/All-Hands-AI/OpenHands-resolver?tab=readme-ov-file#using-the-github-actions-workflow).
|
||||
Pour installer l'Action GitHub OpenHands dans votre propre dépôt, suivez les [instructions dans le dépôt OpenHands Resolver](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
|
||||
## 在新仓库中安装 Action
|
||||
|
||||
要在你自己的仓库中安装 OpenHands GitHub Action,请按照 [OpenHands Resolver 仓库中的说明](https://github.com/All-Hands-AI/OpenHands-resolver?tab=readme-ov-file#using-the-github-actions-workflow) 进行操作。
|
||||
要在你自己的仓库中安装 OpenHands GitHub Action,请按照 [OpenHands Resolver 仓库中的说明](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md) 进行操作。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 📚 Misc
|
||||
# About OpenHands
|
||||
|
||||
## ⭐️ Research Strategy
|
||||
## Research Strategy
|
||||
|
||||
Achieving full replication of production-grade applications with LLMs is a complex endeavor. Our strategy involves:
|
||||
|
||||
@@ -9,34 +9,11 @@ Achieving full replication of production-grade applications with LLMs is a compl
|
||||
3. **Task Planning:** Developing capabilities for bug detection, codebase management, and optimization
|
||||
4. **Evaluation:** Establishing comprehensive evaluation metrics to better understand and improve our models
|
||||
|
||||
## 🚧 Default Agent
|
||||
## Default Agent
|
||||
|
||||
Our default Agent is currently the [CodeActAgent](agents), which is capable of generating code and handling files.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. Whether you're a developer, a researcher, or simply enthusiastic about advancing the field of software engineering with AI, there are many ways to get involved:
|
||||
|
||||
- **Code Contributions:** Help us develop the core functionalities, frontend interface, or sandboxing solutions
|
||||
- **Research and Evaluation:** Contribute to our understanding of LLMs in software engineering, participate in evaluating the models, or suggest improvements
|
||||
- **Feedback and Testing:** Use the OpenHands toolset, report bugs, suggest features, or provide feedback on usability
|
||||
|
||||
For details, please check [this document](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## 🤖 Join Our Community
|
||||
|
||||
We have both Slack workspace for the collaboration on building OpenHands and Discord server for discussion about anything related, e.g., this project, LLM, agent, etc.
|
||||
|
||||
- [Slack workspace](https://join.slack.com/t/opendevin/shared_invite/zt-2oikve2hu-UDxHeo8nsE69y6T7yFX_BA)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
|
||||
If you would love to contribute, feel free to join our community. Let's simplify software engineering together!
|
||||
|
||||
🐚 **Code less, make more with OpenHands.**
|
||||
|
||||
[](https://star-history.com/#All-Hands-AI/OpenHands&Date)
|
||||
|
||||
## 🛠️ Built With
|
||||
## Built With
|
||||
|
||||
OpenHands is built using a combination of powerful frameworks and libraries, providing a robust foundation for its development. Here are the key technologies used in the project:
|
||||
|
||||
@@ -44,6 +21,6 @@ OpenHands is built using a combination of powerful frameworks and libraries, pro
|
||||
|
||||
Please note that the selection of these technologies is in progress, and additional technologies may be added or existing ones may be removed as the project evolves. We strive to adopt the most suitable and efficient tools to enhance the capabilities of OpenHands.
|
||||
|
||||
## 📜 License
|
||||
## License
|
||||
|
||||
Distributed under the MIT License. See [our license](https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE) for more information.
|
||||
Distributed under MIT [License](https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE).
|
||||
|
||||
@@ -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.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.14-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.13 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.14 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -62,25 +62,3 @@ Run OpenHands by running ```make run``` in the top level directory.
|
||||
## Technical Explanation
|
||||
|
||||
Please refer to [custom docker image section of the runtime documentation](https://docs.all-hands.dev/modules/usage/architecture/runtime#advanced-how-openhands-builds-and-maintains-od-runtime-images) for more details.
|
||||
|
||||
## Troubleshooting / Errors
|
||||
|
||||
### Error: ```useradd: UID 1000 is not unique```
|
||||
|
||||
If you see this error in the console output it is because OpenHands is trying to create the openhands user in the sandbox with a UID of 1000, however this UID is already being used in the image (for some reason). To fix this change the sandbox_user_id field in the config.toml file to a different value:
|
||||
|
||||
```toml
|
||||
[core]
|
||||
workspace_base="./workspace"
|
||||
run_as_openhands=true
|
||||
sandbox_base_container_image="custom_image"
|
||||
sandbox_user_id="1001"
|
||||
```
|
||||
|
||||
### Port use errors
|
||||
|
||||
If you see an error about a port being in use or unavailable, try deleting all running Docker Containers (run `docker ps` and `docker rm` relevant containers) and then re-running ```make run``` .
|
||||
|
||||
## Discuss
|
||||
|
||||
For other issues or questions join the [Slack](https://join.slack.com/t/opendevin/shared_invite/zt-2oikve2hu-UDxHeo8nsE69y6T7yFX_BA) or [Discord](https://discord.gg/ESHStjSjD4) and ask!
|
||||
|
||||
@@ -4,12 +4,42 @@ This guide explains how to use the OpenHands GitHub Action, both within the Open
|
||||
|
||||
## Using the Action in the OpenHands Repository
|
||||
|
||||
To use the OpenHands GitHub Action in the OpenHands repository, an OpenHands maintainer can:
|
||||
To use the OpenHands GitHub Action in a repository, you can:
|
||||
|
||||
1. Create an issue in the repository.
|
||||
2. Add the `fix-me` label to the issue.
|
||||
3. The action will automatically trigger and attempt to resolve the issue.
|
||||
2. Add the `fix-me` label to the issue or leave a comment on the issue starting with `@openhands-agent`.
|
||||
|
||||
The action will automatically trigger and attempt to resolve the issue.
|
||||
|
||||
## Installing the Action in a New Repository
|
||||
|
||||
To install the OpenHands GitHub Action in your own repository, follow the [directions in the OpenHands Resolver repo](https://github.com/All-Hands-AI/OpenHands-resolver?tab=readme-ov-file#using-the-github-actions-workflow).
|
||||
To install the OpenHands GitHub Action in your own repository, follow
|
||||
the [README for the OpenHands Resolver](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
|
||||
|
||||
## Usage Tips
|
||||
|
||||
### Iterative resolution
|
||||
|
||||
1. Create an issue in the repository.
|
||||
2. Add the `fix-me` label to the issue, or leave a comment starting with `@openhands-agent`
|
||||
3. Review the attempt to resolve the issue by checking the pull request
|
||||
4. Follow up with feedback through general comments, review comments, or inline thread comments
|
||||
5. Add the `fix-me` label to the pull request, or address a specific comment by starting with `@openhands-agent`
|
||||
|
||||
### Label versus Macro
|
||||
|
||||
- Label (`fix-me`): Requests OpenHands to address the **entire** issue or pull request.
|
||||
- Macro (`@openhands-agent`): Requests OpenHands to consider only the issue/pull request description and **the specific comment**.
|
||||
|
||||
## Advanced Settings
|
||||
|
||||
### Add custom repository settings
|
||||
|
||||
You can provide custom directions for OpenHands by following the [README for the resolver](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md#providing-custom-instructions).
|
||||
|
||||
### Configure custom macro
|
||||
|
||||
To customize the default macro (`@openhands-agent`):
|
||||
|
||||
1. [Create a repository variable](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) named `OPENHANDS_MACRO`
|
||||
2. Assign the variable a custom value
|
||||
|
||||
@@ -44,7 +44,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.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -54,6 +54,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.13 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.14 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.14
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -49,7 +49,7 @@ but seems to work well on most systems.
|
||||
|
||||
## All Hands Runtime
|
||||
The All Hands Runtime is currently in beta. You can request access by joining
|
||||
the #remote-runtime-limited-beta channel on Slack (see the README for an invite).
|
||||
the #remote-runtime-limited-beta channel on Slack ([see the README](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-join-our-community) for an invite).
|
||||
|
||||
To use the All Hands Runtime, set the following environment variables when
|
||||
starting OpenHands:
|
||||
@@ -66,7 +66,7 @@ docker run # ...
|
||||
## Modal Runtime
|
||||
Our partners at [Modal](https://modal.com/) have also provided a runtime for OpenHands.
|
||||
|
||||
To use the Modal Runtime, create an account, and then [create an API key](https://modal.com/settings)
|
||||
To use the Modal Runtime, create an account, and then [create an API key.](https://modal.com/settings)
|
||||
|
||||
You'll then need to set the following environment variables when starting OpenHands:
|
||||
```bash
|
||||
|
||||
2668
docs/package-lock.json
generated
2668
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,10 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.6.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.0",
|
||||
"@docusaurus/preset-classic": "^3.6.0",
|
||||
"@docusaurus/theme-mermaid": "^3.6.0",
|
||||
"@docusaurus/core": "^3.6.2",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.2",
|
||||
"@docusaurus/preset-classic": "^3.6.2",
|
||||
"@docusaurus/theme-mermaid": "^3.6.2",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.6.0",
|
||||
"@docusaurus/tsconfig": "^3.6.2",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
|
||||
1041
docs/yarn.lock
1041
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -250,9 +250,6 @@ def process_instance(
|
||||
|
||||
config = get_config(metadata)
|
||||
|
||||
# use a session id for concurrent evaluation
|
||||
sid = 'ID_' + str(instance.instance_id)
|
||||
|
||||
# Setup the logger properly, so you can run
|
||||
# multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
@@ -284,7 +281,7 @@ def process_instance(
|
||||
instruction += AGENT_CLS_TO_INST_SUFFIX[metadata.agent_class]
|
||||
|
||||
# Here's how you can run the agent (similar to the `main` function) and get the final task state
|
||||
runtime = create_runtime(config, sid=sid)
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
initialize_runtime(runtime, instance.data_files)
|
||||
|
||||
|
||||
@@ -263,23 +263,29 @@ def process_instance(
|
||||
test_output_path = os.path.join(log_dir, 'test_output.txt')
|
||||
with open(test_output_path, 'w') as f:
|
||||
f.write(test_output)
|
||||
|
||||
_report = get_eval_report(
|
||||
test_spec=test_spec,
|
||||
prediction={
|
||||
'model_patch': model_patch,
|
||||
'instance_id': instance_id,
|
||||
},
|
||||
log_path=test_output_path,
|
||||
include_tests_status=True,
|
||||
)
|
||||
report = _report[instance_id]
|
||||
logger.info(
|
||||
f"[{instance_id}] report: {report}\nResult for {instance_id}: resolved: {report['resolved']}"
|
||||
)
|
||||
instance['test_result']['report']['resolved'] = report[
|
||||
'resolved'
|
||||
]
|
||||
try:
|
||||
_report = get_eval_report(
|
||||
test_spec=test_spec,
|
||||
prediction={
|
||||
'model_patch': model_patch,
|
||||
'instance_id': instance_id,
|
||||
},
|
||||
log_path=test_output_path,
|
||||
include_tests_status=True,
|
||||
)
|
||||
report = _report[instance_id]
|
||||
logger.info(
|
||||
f"[{instance_id}] report: {report}\nResult for {instance_id}: resolved: {report['resolved']}"
|
||||
)
|
||||
instance['test_result']['report']['resolved'] = report[
|
||||
'resolved'
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[{instance_id}] Error when getting eval report: {e}'
|
||||
)
|
||||
instance['test_result']['report']['resolved'] = False
|
||||
instance['test_result']['report']['error_eval'] = True
|
||||
else:
|
||||
logger.info(f'[{instance_id}] Error when starting eval:\n{obs.content}')
|
||||
instance['test_result']['report']['error_eval'] = True
|
||||
@@ -355,7 +361,7 @@ if __name__ == '__main__':
|
||||
|
||||
if 'model_patch' not in predictions.columns:
|
||||
predictions['model_patch'] = predictions['test_result'].apply(
|
||||
lambda x: x['git_patch']
|
||||
lambda x: x.get('git_patch', '')
|
||||
)
|
||||
assert {'instance_id', 'model_patch'}.issubset(
|
||||
set(predictions.columns)
|
||||
|
||||
@@ -145,7 +145,7 @@ def get_config(
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
),
|
||||
# do not mount workspace
|
||||
@@ -534,5 +534,10 @@ if __name__ == '__main__':
|
||||
instances[col] = instances[col].apply(lambda x: str(x))
|
||||
|
||||
run_evaluation(
|
||||
instances, metadata, output_file, args.eval_num_workers, process_instance
|
||||
instances,
|
||||
metadata,
|
||||
output_file,
|
||||
args.eval_num_workers,
|
||||
process_instance,
|
||||
timeout_seconds=120 * 60, # 2 hour PER instance should be more than enough
|
||||
)
|
||||
|
||||
@@ -3,9 +3,11 @@ import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Awaitable, Callable, TextIO
|
||||
|
||||
import pandas as pd
|
||||
@@ -92,6 +94,27 @@ class EvalException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EvalTimeoutException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def timeout(seconds: int):
|
||||
def timeout_handler(signum, frame):
|
||||
raise EvalTimeoutException(f'Function timed out after {seconds} seconds')
|
||||
|
||||
# Set up the signal handler
|
||||
original_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(seconds)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore the original handler and disable the alarm
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, original_handler)
|
||||
|
||||
|
||||
def codeact_user_response(
|
||||
state: State,
|
||||
encapsulate_solution: bool = False,
|
||||
@@ -280,15 +303,33 @@ def _process_instance_wrapper(
|
||||
metadata: EvalMetadata,
|
||||
use_mp: bool,
|
||||
max_retries: int = 5,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> EvalOutput:
|
||||
"""Wrap the process_instance_func to handle retries and errors.
|
||||
|
||||
Retry an instance up to max_retries times if it fails (e.g., due to transient network/runtime issues).
|
||||
"""
|
||||
"""Wrap the process_instance_func to handle retries and errors."""
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
result = process_instance_func(instance, metadata, use_mp)
|
||||
if timeout_seconds is not None:
|
||||
with timeout(timeout_seconds):
|
||||
result = process_instance_func(instance, metadata, use_mp)
|
||||
else:
|
||||
result = process_instance_func(instance, metadata, use_mp)
|
||||
return result
|
||||
except EvalTimeoutException as e:
|
||||
error = f'Timeout after {timeout_seconds} seconds'
|
||||
stacktrace = traceback.format_exc()
|
||||
msg = (
|
||||
'-' * 10
|
||||
+ '\n'
|
||||
+ f'Timeout ({timeout_seconds} seconds) in instance [{instance.instance_id}], Stopped evaluation for this instance.'
|
||||
+ '\n'
|
||||
+ '-' * 10
|
||||
)
|
||||
logger.exception(e)
|
||||
return EvalOutput(
|
||||
instance_id=instance.instance_id,
|
||||
test_result={},
|
||||
error=error,
|
||||
)
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
stacktrace = traceback.format_exc()
|
||||
@@ -337,6 +378,7 @@ def run_evaluation(
|
||||
[pd.Series, EvalMetadata, bool], Awaitable[EvalOutput]
|
||||
],
|
||||
max_retries: int = 5, # number of retries for each instance
|
||||
timeout_seconds: int | None = None,
|
||||
):
|
||||
use_multiprocessing = num_workers > 1
|
||||
|
||||
@@ -357,7 +399,14 @@ def run_evaluation(
|
||||
if use_multiprocessing:
|
||||
with mp.Pool(num_workers) as pool:
|
||||
args_iter = (
|
||||
(process_instance_func, instance, metadata, True, max_retries)
|
||||
(
|
||||
process_instance_func,
|
||||
instance,
|
||||
metadata,
|
||||
True,
|
||||
max_retries,
|
||||
timeout_seconds,
|
||||
)
|
||||
for _, instance in dataset.iterrows()
|
||||
)
|
||||
results = pool.imap_unordered(_process_instance_wrapper_mp, args_iter)
|
||||
|
||||
@@ -21,6 +21,11 @@ describe("Empty state", () => {
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("@remix-run/react", async (importActual) => ({
|
||||
...(await importActual<typeof import("@remix-run/react")>()),
|
||||
useRouteLoaderData: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/socket", async (importActual) => ({
|
||||
...(await importActual<typeof import("#/context/ws-client-provider")>()),
|
||||
useWsClient: useWsClientMock,
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.1",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -120,4 +120,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,12 @@ class OpenHands {
|
||||
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
|
||||
return request(`/api/vscode-url`, {}, false, false, 1);
|
||||
}
|
||||
|
||||
static async getRuntimeId(): Promise<{ runtime_id: string }> {
|
||||
const data = await request("/api/conversation");
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useRouteLoaderData } from "@remix-run/react";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { FeedbackActions } from "./feedback-actions";
|
||||
@@ -21,19 +22,27 @@ import { ScrollToBottomButton } from "./scroll-to-bottom-button";
|
||||
import { Suggestions } from "./suggestions";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { clientLoader } from "#/routes/_oh";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { SuggestionItem } from "./suggestion-item";
|
||||
|
||||
const isErrorMessage = (
|
||||
message: Message | ErrorMessage,
|
||||
): message is ErrorMessage => "error" in message;
|
||||
|
||||
export function ChatInterface() {
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const { send, status, isLoadingMessages } = useWsClient();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
useScrollToBottom(scrollRef);
|
||||
const rootLoaderData = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
|
||||
const { messages } = useSelector((state: RootState) => state.chat);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
@@ -43,6 +52,24 @@ export function ChatInterface() {
|
||||
>("positive");
|
||||
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
|
||||
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === WsClientProviderStatus.ACTIVE) {
|
||||
try {
|
||||
OpenHands.getRuntimeId().then(({ runtime_id }) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtime_id,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Runtime ID not available in this environment");
|
||||
}
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
posthog.capture("user_message_sent", {
|
||||
@@ -73,6 +100,17 @@ export function ChatInterface() {
|
||||
setFeedbackPolarity(polarity);
|
||||
};
|
||||
|
||||
const handleDownloadWorkspace = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadWorkspace();
|
||||
} catch (error) {
|
||||
// TODO: Handle error
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{messages.length === 0 && (
|
||||
@@ -107,6 +145,7 @@ export function ChatInterface() {
|
||||
<div className="w-6 h-6 border-2 border-t-[4px] border-primary-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages &&
|
||||
messages.map((message, index) =>
|
||||
isErrorMessage(message) ? (
|
||||
@@ -132,6 +171,34 @@ export function ChatInterface() {
|
||||
</ChatMessage>
|
||||
),
|
||||
)}
|
||||
|
||||
{(curAgentState === AgentState.AWAITING_USER_INPUT ||
|
||||
curAgentState === AgentState.FINISHED) && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{rootLoaderData?.ghToken ? (
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: "Push to GitHub",
|
||||
value:
|
||||
"Please push the changes to GitHub and open a pull request.",
|
||||
}}
|
||||
onClick={(value) => {
|
||||
handleSendMessage(value, []);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: !isDownloading
|
||||
? "Download .zip"
|
||||
: "Downloading, please wait...",
|
||||
value: "Download .zip",
|
||||
}}
|
||||
onClick={handleDownloadWorkspace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[6px] px-4 pb-4">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "#/services/terminalService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearInitialQuery,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
@@ -52,13 +53,10 @@ export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
|
||||
const fetcher = useFetcher();
|
||||
const dispatch = useDispatch();
|
||||
const { files, importedProjectZip } = useSelector(
|
||||
const { files, importedProjectZip, initialQuery } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
|
||||
const initialQueryRef = React.useRef<string | null>(
|
||||
store.getState().initalQuery.initialQuery,
|
||||
);
|
||||
|
||||
const sendInitialQuery = (query: string, base64Files: string[]) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -119,7 +117,6 @@ export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
return; // This is a check because of strict mode - if the status did not change, don't do anything
|
||||
}
|
||||
statusRef.current = status;
|
||||
const initialQuery = initialQueryRef.current;
|
||||
|
||||
if (status === WsClientProviderStatus.ACTIVE) {
|
||||
let additionalInfo = "";
|
||||
@@ -140,7 +137,7 @@ export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
sendInitialQuery(initialQuery, files);
|
||||
}
|
||||
dispatch(clearFiles()); // reset selected files
|
||||
initialQueryRef.current = null;
|
||||
dispatch(clearInitialQuery()); // reset initial query
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,32 +10,8 @@ import { GitHubRepositorySelector } from "#/routes/_oh._index/github-repo-select
|
||||
import ModalButton from "./buttons/ModalButton";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
|
||||
interface GitHubAuthProps {
|
||||
onConnectToGitHub: () => void;
|
||||
repositories: GitHubRepository[];
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
function GitHubAuth({
|
||||
onConnectToGitHub,
|
||||
repositories,
|
||||
isLoggedIn,
|
||||
}: GitHubAuthProps) {
|
||||
if (isLoggedIn) {
|
||||
return <GitHubRepositorySelector repositories={repositories} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalButton
|
||||
text="Connect to GitHub"
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={onConnectToGitHub}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GitHubRepositoriesSuggestionBoxProps {
|
||||
handleSubmit: () => void;
|
||||
repositories: Awaited<
|
||||
ReturnType<typeof retrieveAllGitHubUserRepositories>
|
||||
> | null;
|
||||
@@ -44,6 +20,7 @@ interface GitHubRepositoriesSuggestionBoxProps {
|
||||
}
|
||||
|
||||
export function GitHubRepositoriesSuggestionBox({
|
||||
handleSubmit,
|
||||
repositories,
|
||||
gitHubAuthUrl,
|
||||
user,
|
||||
@@ -70,16 +47,26 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
);
|
||||
}
|
||||
|
||||
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SuggestionBox
|
||||
title="Open a Repo"
|
||||
content={
|
||||
<GitHubAuth
|
||||
isLoggedIn={!!user && !isGitHubErrorReponse(user)}
|
||||
repositories={repositories || []}
|
||||
onConnectToGitHub={handleConnectToGitHub}
|
||||
/>
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onSelect={handleSubmit}
|
||||
repositories={repositories || []}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
text="Connect to GitHub"
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{connectToGitHubModalOpen && (
|
||||
|
||||
@@ -7,12 +7,12 @@ interface SuggestionItemProps {
|
||||
|
||||
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
return (
|
||||
<li className="border border-neutral-600 rounded-xl hover:bg-neutral-700">
|
||||
<li className="list-none border border-neutral-600 rounded-xl hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="suggestion"
|
||||
onClick={() => onClick(suggestion.value)}
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-4 font-semibold"
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold"
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { setSelectedRepository } from "#/state/initial-query-slice";
|
||||
|
||||
interface GitHubRepositorySelectorProps {
|
||||
onSelect: () => void;
|
||||
repositories: GitHubRepository[];
|
||||
}
|
||||
|
||||
export function GitHubRepositorySelector({
|
||||
onSelect,
|
||||
repositories,
|
||||
}: GitHubRepositorySelectorProps) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleRepoSelection = (id: string | null) => {
|
||||
@@ -18,7 +18,7 @@ export function GitHubRepositorySelector({
|
||||
if (repo) {
|
||||
// set query param
|
||||
dispatch(setSelectedRepository(repo.full_name));
|
||||
navigate("/app");
|
||||
onSelect();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
defer,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import React from "react";
|
||||
@@ -73,10 +72,10 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,7 +85,7 @@ function Home() {
|
||||
<HeroHeading />
|
||||
<div className="flex flex-col gap-16 w-[600px] items-center">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<TaskForm />
|
||||
<TaskForm ref={formRef} />
|
||||
</div>
|
||||
<div className="flex gap-4 w-full">
|
||||
<React.Suspense
|
||||
@@ -100,6 +99,7 @@ function Home() {
|
||||
<Await resolve={repositories}>
|
||||
{(resolvedRepositories) => (
|
||||
<GitHubRepositoriesSuggestionBox
|
||||
handleSubmit={() => formRef.current?.requestSubmit()}
|
||||
repositories={resolvedRepositories}
|
||||
gitHubAuthUrl={githubAuthUrl}
|
||||
user={rootData?.user || null}
|
||||
@@ -129,7 +129,7 @@ function Home() {
|
||||
dispatch(
|
||||
setImportedProjectZip(await convertZipToBase64(zip)),
|
||||
);
|
||||
navigate("/app");
|
||||
formRef.current?.requestSubmit();
|
||||
} else {
|
||||
// TODO: handle error
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getRandomKey } from "#/utils/get-random-key";
|
||||
import { AttachImageLabel } from "#/components/attach-image-label";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function TaskForm() {
|
||||
export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -21,7 +21,6 @@ export function TaskForm() {
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const [text, setText] = React.useState("");
|
||||
const [suggestion, setSuggestion] = React.useState(
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
@@ -55,7 +54,7 @@ export function TaskForm() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Form
|
||||
ref={formRef}
|
||||
ref={ref}
|
||||
method="post"
|
||||
className="flex flex-col items-center gap-2"
|
||||
replace
|
||||
@@ -75,7 +74,7 @@ export function TaskForm() {
|
||||
<ChatInput
|
||||
name="q"
|
||||
onSubmit={() => {
|
||||
formRef.current?.requestSubmit();
|
||||
if (typeof ref !== "function") ref?.current?.requestSubmit();
|
||||
}}
|
||||
onChange={(message) => setText(message)}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
@@ -116,4 +115,6 @@ export function TaskForm() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
TaskForm.displayName = "TaskForm";
|
||||
|
||||
@@ -7,15 +7,15 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { useFiles } from "#/context/files";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
interface CodeEditorCompoonentProps {
|
||||
interface CodeEditorComponentProps {
|
||||
onMount: EditorProps["onMount"];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
function CodeEditorCompoonent({
|
||||
function CodeEditorComponent({
|
||||
onMount,
|
||||
isReadOnly,
|
||||
}: CodeEditorCompoonentProps) {
|
||||
}: CodeEditorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
files,
|
||||
@@ -107,4 +107,4 @@ function CodeEditorCompoonent({
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CodeEditorCompoonent);
|
||||
export default React.memo(CodeEditorComponent);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import FileExplorer from "#/components/file-explorer/FileExplorer";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import CodeEditorCompoonent from "./code-editor-component";
|
||||
import CodeEditorComponent from "./code-editor-component";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { EditorActions } from "#/components/editor-actions";
|
||||
|
||||
@@ -138,7 +138,7 @@ function CodeEditor() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CodeEditorCompoonent
|
||||
<CodeEditorComponent
|
||||
onMount={handleEditorDidMount}
|
||||
isReadOnly={!isEditingAllowed}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useEffectOnce } from "#/utils/use-effect-once";
|
||||
import CodeIcon from "#/icons/code.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ListIcon from "#/icons/list-type-number.svg?react";
|
||||
import { clearInitialQuery } from "#/state/initial-query-slice";
|
||||
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
|
||||
import { clearJupyter } from "#/state/jupyterSlice";
|
||||
import { FilesProvider } from "#/context/files";
|
||||
@@ -28,8 +27,6 @@ import { EventHandler } from "#/components/event-handler";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
|
||||
const q = store.getState().initalQuery.initialQuery;
|
||||
const repo =
|
||||
store.getState().initalQuery.selectedRepository ||
|
||||
localStorage.getItem("repo");
|
||||
@@ -55,7 +52,6 @@ export const clientLoader = async () => {
|
||||
token,
|
||||
ghToken,
|
||||
repo,
|
||||
q,
|
||||
lastCommit,
|
||||
});
|
||||
};
|
||||
@@ -91,7 +87,6 @@ function App() {
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
@@ -59,3 +59,29 @@ test("should redirect to /app after selecting a repo", async ({ page }) => {
|
||||
await page.waitForURL("/app");
|
||||
expect(page.url()).toBe("http://127.0.0.1:3000/app");
|
||||
});
|
||||
|
||||
// FIXME: This fails because the MSW WS mocks change state too quickly,
|
||||
// missing the OPENING status where the initial query is rendered.
|
||||
test.fail(
|
||||
"should redirect the user to /app with their initial query after selecting a project",
|
||||
async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await confirmSettings(page);
|
||||
|
||||
// enter query
|
||||
const testQuery = "this is my test query";
|
||||
const textbox = page.getByPlaceholder(/what do you want to build/i);
|
||||
expect(textbox).not.toBeNull();
|
||||
await textbox.fill(testQuery);
|
||||
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
|
||||
await page.waitForURL("/app");
|
||||
|
||||
// get user message
|
||||
const userMessage = page.getByTestId("user-message");
|
||||
expect(await userMessage.textContent()).toBe(testQuery);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ export default defineConfig(({ mode }) => {
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["vitest.setup.ts"],
|
||||
reporters: "basic",
|
||||
exclude: [...configDefaults.exclude, "tests"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html", "lcov", "text-summary"],
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
// Mock requests during tests
|
||||
beforeAll(() => server.listen());
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
// Cleanup the document body after each test
|
||||
|
||||
@@ -10,3 +10,57 @@ The conceptual idea is illustrated below. At each turn, the agent can:
|
||||
- Execute any valid `Python` code with [an interactive Python interpreter](https://ipython.org/). This is simulated through `bash` command, see plugin system below for more details.
|
||||
|
||||

|
||||
|
||||
## Adding New Tools
|
||||
|
||||
The CodeAct agent uses a function calling interface to define tools that the agent can use. Tools are defined in `function_calling.py` using the `ChatCompletionToolParam` class from `litellm`. Each tool consists of:
|
||||
|
||||
1. A description string that explains what the tool does and how to use it
|
||||
2. A tool definition using `ChatCompletionToolParam` that specifies:
|
||||
- The tool's name
|
||||
- The tool's parameters and their types
|
||||
- Required vs optional parameters
|
||||
|
||||
Here's an example of how a tool is defined:
|
||||
|
||||
```python
|
||||
MyTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='my_tool',
|
||||
description='Description of what the tool does and how to use it',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'param1': {
|
||||
'type': 'string',
|
||||
'description': 'Description of parameter 1',
|
||||
},
|
||||
'param2': {
|
||||
'type': 'integer',
|
||||
'description': 'Description of parameter 2',
|
||||
},
|
||||
},
|
||||
'required': ['param1'], # List required parameters here
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
To add a new tool:
|
||||
|
||||
1. Define your tool in `function_calling.py` following the pattern above
|
||||
2. Add your tool to the `get_tools()` function in `function_calling.py`
|
||||
3. Implement the corresponding action handler in the agent to process the tool's invocation
|
||||
|
||||
The agent currently supports several built-in tools:
|
||||
- `execute_bash`: Execute bash commands
|
||||
- `execute_ipython_cell`: Run Python code in IPython
|
||||
- `browser`: Interact with a web browser
|
||||
- `str_replace_editor`: Edit files using string replacement
|
||||
- `edit_file`: Edit files using LLM-based editing
|
||||
|
||||
Tools can be enabled/disabled through configuration parameters:
|
||||
- `codeact_enable_browsing`: Enable browser interaction
|
||||
- `codeact_enable_jupyter`: Enable IPython code execution
|
||||
- `codeact_enable_llm_editor`: Enable LLM-based file editing (if disabled, uses string replacement editor instead)
|
||||
|
||||
@@ -12,6 +12,7 @@ from litellm import (
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
from openhands.core.exceptions import FunctionCallNotExistsError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
@@ -484,7 +485,9 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
elif tool_call.function.name == 'browser':
|
||||
action = BrowseInteractiveAction(browser_actions=arguments['code'])
|
||||
else:
|
||||
raise RuntimeError(f'Unknown tool call: {tool_call.function.name}')
|
||||
raise FunctionCallNotExistsError(
|
||||
f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'
|
||||
)
|
||||
|
||||
# We only add thought to the first action
|
||||
if i == 0:
|
||||
|
||||
@@ -21,11 +21,9 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
* After opening or updating a pull request, send the user a short message with a link to the pull request.
|
||||
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
|
||||
```bash
|
||||
git checkout -b create-widget
|
||||
git add .
|
||||
git commit -m "Create widget"
|
||||
git push origin create-widget
|
||||
curl -X POST "https://api.github.com/repos/CodeActOrg/openhands/pulls" \
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
curl -X POST "https://api.github.com/repos/$ORG_NAME/$REPO_NAME/pulls" \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
|
||||
```
|
||||
|
||||
@@ -12,11 +12,13 @@ from openhands.controller.state.state import State, TrafficControlState
|
||||
from openhands.controller.stuck import StuckDetector
|
||||
from openhands.core.config import AgentConfig, LLMConfig
|
||||
from openhands.core.exceptions import (
|
||||
FunctionCallNotExistsError,
|
||||
FunctionCallValidationError,
|
||||
LLMMalformedActionError,
|
||||
LLMNoActionError,
|
||||
LLMResponseError,
|
||||
)
|
||||
from openhands.core.logger import LOG_ALL_EVENTS
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events import EventSource, EventStream, EventStreamSubscriber
|
||||
@@ -488,6 +490,7 @@ class AgentController:
|
||||
LLMNoActionError,
|
||||
LLMResponseError,
|
||||
FunctionCallValidationError,
|
||||
FunctionCallNotExistsError,
|
||||
) as e:
|
||||
self.event_stream.add_event(
|
||||
ErrorObservation(
|
||||
@@ -526,8 +529,7 @@ class AgentController:
|
||||
|
||||
await self.update_state_after_step()
|
||||
|
||||
# Use info level if LOG_ALL_EVENTS is set
|
||||
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
|
||||
log_level = 'info' if LOG_ALL_EVENTS else 'debug'
|
||||
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
|
||||
|
||||
async def _delegate_step(self):
|
||||
|
||||
@@ -241,6 +241,7 @@ def get_llm_config_arg(
|
||||
|
||||
Args:
|
||||
llm_config_arg: The group of llm settings to get from the config.toml file.
|
||||
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
|
||||
|
||||
Returns:
|
||||
LLMConfig: The LLMConfig object with the settings from the config file.
|
||||
@@ -384,7 +385,7 @@ def load_app_config(
|
||||
"""Load the configuration from the specified config file and environment variables.
|
||||
|
||||
Args:
|
||||
set_logger_levels: Whether to set the global variables for logging levels.
|
||||
set_logging_levels: Whether to set the global variables for logging levels.
|
||||
config_file: Path to the config file. Defaults to 'config.toml' in the current directory.
|
||||
"""
|
||||
config = AppConfig()
|
||||
|
||||
@@ -114,3 +114,10 @@ class FunctionCallValidationError(Exception):
|
||||
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class FunctionCallNotExistsError(Exception):
|
||||
"""Exception raised when an LLM call a tool that is not registered."""
|
||||
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
|
||||
@@ -17,6 +17,8 @@ if DEBUG:
|
||||
LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'False').lower() in ['true', '1', 'yes']
|
||||
DISABLE_COLOR_PRINTING = False
|
||||
|
||||
LOG_ALL_EVENTS = os.getenv('LOG_ALL_EVENTS', 'False').lower() in ['true', '1', 'yes']
|
||||
|
||||
ColorType = Literal[
|
||||
'red',
|
||||
'green',
|
||||
@@ -89,8 +91,11 @@ class ColoredFormatter(logging.Formatter):
|
||||
return f'{time_str} - {name_str}:{level_str}: {record.filename}:{record.lineno}\n{msg_type_color}\n{msg}'
|
||||
return f'{time_str} - {msg_type_color}\n{msg}'
|
||||
elif msg_type == 'STEP':
|
||||
msg = '\n\n==============\n' + record.msg + '\n'
|
||||
return f'{msg}'
|
||||
if LOG_ALL_EVENTS:
|
||||
msg = '\n\n==============\n' + record.msg + '\n'
|
||||
return f'{msg}'
|
||||
else:
|
||||
return record.msg
|
||||
return super().format(record)
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ def create_runtime(
|
||||
"""Create a runtime for the agent to run on.
|
||||
|
||||
config: The app config.
|
||||
sid: The session id.
|
||||
sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
Set it to incompatible value will cause unexpected behavior on RemoteRuntime.
|
||||
headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts,
|
||||
where we don't want to have the VSCode UI open, so it defaults to True.
|
||||
"""
|
||||
@@ -105,6 +106,8 @@ async def run_controller(
|
||||
Args:
|
||||
config: The app config.
|
||||
initial_user_action: An Action object containing initial user input
|
||||
sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
Set it to incompatible value will cause unexpected behavior on RemoteRuntime.
|
||||
runtime: (optional) A runtime for the agent to run on.
|
||||
agent: (optional) A agent to run.
|
||||
exit_on_message: quit if agent asks for a message from user (optional)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Linter module for OpenHands.
|
||||
|
||||
Part of this Linter module is adapted from Aider (Apache 2.0 License, [original code](https://github.com/paul-gauthier/aider/blob/main/aider/linter.py)). Please see the [original repository](https://github.com/paul-gauthier/aider) for more information.
|
||||
Part of this Linter module is adapted from Aider (Apache 2.0 License, [original
|
||||
code](https://github.com/paul-gauthier/aider/blob/main/aider/linter.py)).
|
||||
- Please see the [original repository](https://github.com/paul-gauthier/aider) for more information.
|
||||
- The detailed implementation of the linter can be found at: https://github.com/All-Hands-AI/openhands-aci.
|
||||
"""
|
||||
|
||||
from openhands.linter.base import LintResult
|
||||
from openhands.linter.linter import DefaultLinter
|
||||
from openhands_aci.linter import DefaultLinter, LintResult
|
||||
|
||||
__all__ = ['DefaultLinter', 'LintResult']
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LintResult(BaseModel):
|
||||
file: str
|
||||
line: int # 1-indexed
|
||||
column: int # 1-indexed
|
||||
message: str
|
||||
|
||||
def visualize(self, half_window: int = 3) -> str:
|
||||
"""Visualize the lint result by print out all the lines where the lint result is found.
|
||||
|
||||
Args:
|
||||
half_window: The number of context lines to display around the error on each side.
|
||||
"""
|
||||
with open(self.file, 'r') as f:
|
||||
file_lines = f.readlines()
|
||||
|
||||
# Add line numbers
|
||||
_span_size = len(str(len(file_lines)))
|
||||
file_lines = [
|
||||
f'{i + 1:>{_span_size}}|{line.rstrip()}'
|
||||
for i, line in enumerate(file_lines)
|
||||
]
|
||||
|
||||
# Get the window of lines to display
|
||||
assert self.line <= len(file_lines) and self.line > 0
|
||||
line_idx = self.line - 1
|
||||
begin_window = max(0, line_idx - half_window)
|
||||
end_window = min(len(file_lines), line_idx + half_window + 1)
|
||||
|
||||
selected_lines = file_lines[begin_window:end_window]
|
||||
line_idx_in_window = line_idx - begin_window
|
||||
|
||||
# Add character hint
|
||||
_character_hint = (
|
||||
_span_size * ' '
|
||||
+ ' ' * (self.column)
|
||||
+ '^'
|
||||
+ ' ERROR HERE: '
|
||||
+ self.message
|
||||
)
|
||||
selected_lines[line_idx_in_window] = (
|
||||
f'\033[91m{selected_lines[line_idx_in_window]}\033[0m'
|
||||
+ '\n'
|
||||
+ _character_hint
|
||||
)
|
||||
return '\n'.join(selected_lines)
|
||||
|
||||
|
||||
class LinterException(Exception):
|
||||
"""Base class for all linter exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseLinter(ABC):
|
||||
"""Base class for all linters.
|
||||
|
||||
Each linter should be able to lint files of a specific type and return a list of (parsed) lint results.
|
||||
"""
|
||||
|
||||
encoding: str = 'utf-8'
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_extensions(self) -> list[str]:
|
||||
"""The file extensions that this linter supports, such as .py or .tsx."""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
"""Lint the given file.
|
||||
|
||||
file_path: The path to the file to lint. Required to be absolute.
|
||||
"""
|
||||
pass
|
||||
@@ -1,98 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.linter.base import BaseLinter, LintResult
|
||||
from openhands.linter.utils import run_cmd
|
||||
|
||||
|
||||
def python_compile_lint(fname: str) -> list[LintResult]:
|
||||
try:
|
||||
with open(fname, 'r') as f:
|
||||
code = f.read()
|
||||
compile(code, fname, 'exec') # USE TRACEBACK BELOW HERE
|
||||
return []
|
||||
except SyntaxError as err:
|
||||
err_lineno = getattr(err, 'end_lineno', err.lineno)
|
||||
err_offset = getattr(err, 'end_offset', err.offset)
|
||||
if err_offset and err_offset < 0:
|
||||
err_offset = err.offset
|
||||
return [
|
||||
LintResult(
|
||||
file=fname, line=err_lineno, column=err_offset or 1, message=err.msg
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def flake_lint(filepath: str) -> list[LintResult]:
|
||||
fatal = 'F821,F822,F831,E112,E113,E999,E902'
|
||||
flake8_cmd = f'flake8 --select={fatal} --isolated {filepath}'
|
||||
|
||||
try:
|
||||
cmd_outputs = run_cmd(flake8_cmd)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
results: list[LintResult] = []
|
||||
if not cmd_outputs:
|
||||
return results
|
||||
for line in cmd_outputs.splitlines():
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 4:
|
||||
_msg = parts[3].strip()
|
||||
if len(parts) > 4:
|
||||
_msg += ': ' + parts[4].strip()
|
||||
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
f'Error parsing flake8 output for line: {e}. Parsed parts: {parts}. Skipping...'
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
column_num = int(parts[2])
|
||||
except ValueError as e:
|
||||
column_num = 1
|
||||
_msg = (
|
||||
parts[2].strip() + ' ' + _msg
|
||||
) # add the unparsed message to the original message
|
||||
logger.warning(
|
||||
f'Error parsing flake8 output for column: {e}. Parsed parts: {parts}. Using default column 1.'
|
||||
)
|
||||
|
||||
results.append(
|
||||
LintResult(
|
||||
file=filepath,
|
||||
line=line_num,
|
||||
column=column_num,
|
||||
message=_msg,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class PythonLinter(BaseLinter):
|
||||
@property
|
||||
def supported_extensions(self) -> List[str]:
|
||||
return ['.py']
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
error = flake_lint(file_path)
|
||||
if not error:
|
||||
error = python_compile_lint(file_path)
|
||||
return error
|
||||
|
||||
def compile_lint(self, file_path: str, code: str) -> List[LintResult]:
|
||||
try:
|
||||
compile(code, file_path, 'exec')
|
||||
return []
|
||||
except SyntaxError as e:
|
||||
return [
|
||||
LintResult(
|
||||
file=file_path,
|
||||
line=e.lineno,
|
||||
column=e.offset,
|
||||
message=str(e),
|
||||
rule='SyntaxError',
|
||||
)
|
||||
]
|
||||
@@ -1,74 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from grep_ast import TreeContext, filename_to_lang
|
||||
from grep_ast.parsers import PARSERS
|
||||
from tree_sitter_languages import get_parser
|
||||
|
||||
from openhands.linter.base import BaseLinter, LintResult
|
||||
|
||||
# tree_sitter is throwing a FutureWarning
|
||||
warnings.simplefilter('ignore', category=FutureWarning)
|
||||
|
||||
|
||||
def tree_context(fname, code, line_nums):
|
||||
context = TreeContext(
|
||||
fname,
|
||||
code,
|
||||
color=False,
|
||||
line_number=True,
|
||||
child_context=False,
|
||||
last_line=False,
|
||||
margin=0,
|
||||
mark_lois=True,
|
||||
loi_pad=3,
|
||||
# header_max=30,
|
||||
show_top_of_file_parent_scope=False,
|
||||
)
|
||||
line_nums = set(line_nums)
|
||||
context.add_lines_of_interest(line_nums)
|
||||
context.add_context()
|
||||
output = context.format()
|
||||
return output
|
||||
|
||||
|
||||
def traverse_tree(node):
|
||||
"""Traverses the tree to find errors."""
|
||||
errors = []
|
||||
if node.type == 'ERROR' or node.is_missing:
|
||||
line_no = node.start_point[0] + 1
|
||||
col_no = node.start_point[1] + 1
|
||||
error_type = 'Missing node' if node.is_missing else 'Syntax error'
|
||||
errors.append((line_no, col_no, error_type))
|
||||
|
||||
for child in node.children:
|
||||
errors += traverse_tree(child)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class TreesitterBasicLinter(BaseLinter):
|
||||
@property
|
||||
def supported_extensions(self) -> list[str]:
|
||||
return list(PARSERS.keys())
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
"""Use tree-sitter to look for syntax errors, display them with tree context."""
|
||||
lang = filename_to_lang(file_path)
|
||||
if not lang:
|
||||
return []
|
||||
parser = get_parser(lang)
|
||||
with open(file_path, 'r') as f:
|
||||
code = f.read()
|
||||
tree = parser.parse(bytes(code, 'utf-8'))
|
||||
errors = traverse_tree(tree.root_node)
|
||||
if not errors:
|
||||
return []
|
||||
return [
|
||||
LintResult(
|
||||
file=file_path,
|
||||
line=int(line),
|
||||
column=int(col),
|
||||
message=error_details,
|
||||
)
|
||||
for line, col, error_details in errors
|
||||
]
|
||||
@@ -1,122 +0,0 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from openhands.linter.base import BaseLinter, LinterException, LintResult
|
||||
from openhands.linter.languages.python import PythonLinter
|
||||
from openhands.linter.languages.treesitter import TreesitterBasicLinter
|
||||
|
||||
|
||||
class DefaultLinter(BaseLinter):
|
||||
def __init__(self):
|
||||
self.linters: dict[str, list[BaseLinter]] = defaultdict(list)
|
||||
self.linters['.py'] = [PythonLinter()]
|
||||
|
||||
# Add treesitter linter as a fallback for all linters
|
||||
self.basic_linter = TreesitterBasicLinter()
|
||||
for extension in self.basic_linter.supported_extensions:
|
||||
self.linters[extension].append(self.basic_linter)
|
||||
self._supported_extensions = list(self.linters.keys())
|
||||
|
||||
@property
|
||||
def supported_extensions(self) -> list[str]:
|
||||
return self._supported_extensions
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
if not os.path.isabs(file_path):
|
||||
raise LinterException(f'File path {file_path} is not an absolute path')
|
||||
file_extension = os.path.splitext(file_path)[1]
|
||||
|
||||
linters: list[BaseLinter] = self.linters.get(file_extension, [])
|
||||
for linter in linters:
|
||||
res = linter.lint(file_path)
|
||||
# We always return the first linter's result (higher priority)
|
||||
if res:
|
||||
return res
|
||||
return []
|
||||
|
||||
def lint_file_diff(
|
||||
self, original_file_path: str, updated_file_path: str
|
||||
) -> list[LintResult]:
|
||||
"""Only return lint errors that are introduced by the diff.
|
||||
|
||||
Args:
|
||||
original_file_path: The original file path.
|
||||
updated_file_path: The updated file path.
|
||||
|
||||
Returns:
|
||||
A list of lint errors that are introduced by the diff.
|
||||
"""
|
||||
# 1. Lint the original and updated file
|
||||
original_lint_errors: list[LintResult] = self.lint(original_file_path)
|
||||
updated_lint_errors: list[LintResult] = self.lint(updated_file_path)
|
||||
|
||||
# 2. Load the original and updated file content
|
||||
with open(original_file_path, 'r') as f:
|
||||
old_lines = f.readlines()
|
||||
with open(updated_file_path, 'r') as f:
|
||||
new_lines = f.readlines()
|
||||
|
||||
# 3. Get line numbers that are changed & unchanged
|
||||
# Map the line number of the original file to the updated file
|
||||
# NOTE: this only works for lines that are not changed (i.e., equal)
|
||||
old_to_new_line_no_mapping: dict[int, int] = {}
|
||||
replace_or_inserted_lines: list[int] = []
|
||||
for (
|
||||
tag,
|
||||
old_idx_start,
|
||||
old_idx_end,
|
||||
new_idx_start,
|
||||
new_idx_end,
|
||||
) in SequenceMatcher(
|
||||
isjunk=None,
|
||||
a=old_lines,
|
||||
b=new_lines,
|
||||
).get_opcodes():
|
||||
if tag == 'equal':
|
||||
for idx, _ in enumerate(old_lines[old_idx_start:old_idx_end]):
|
||||
old_to_new_line_no_mapping[old_idx_start + idx + 1] = (
|
||||
new_idx_start + idx + 1
|
||||
)
|
||||
elif tag == 'replace' or tag == 'insert':
|
||||
for idx, _ in enumerate(old_lines[old_idx_start:old_idx_end]):
|
||||
replace_or_inserted_lines.append(new_idx_start + idx + 1)
|
||||
else:
|
||||
# omit the case of delete
|
||||
pass
|
||||
|
||||
# 4. Get pre-existing errors in unchanged lines
|
||||
# increased error elsewhere introduced by the newlines
|
||||
# i.e., we omit errors that are already in original files and report new one
|
||||
new_line_no_to_original_errors: dict[int, list[LintResult]] = defaultdict(list)
|
||||
for error in original_lint_errors:
|
||||
if error.line in old_to_new_line_no_mapping:
|
||||
new_line_no_to_original_errors[
|
||||
old_to_new_line_no_mapping[error.line]
|
||||
].append(error)
|
||||
|
||||
# 5. Select errors from lint results in new file to report
|
||||
selected_errors = []
|
||||
for error in updated_lint_errors:
|
||||
# 5.1. Error introduced by replace/insert
|
||||
if error.line in replace_or_inserted_lines:
|
||||
selected_errors.append(error)
|
||||
# 5.2. Error introduced by modified lines that impacted
|
||||
# the unchanged lines that HAVE pre-existing errors
|
||||
elif error.line in new_line_no_to_original_errors:
|
||||
# skip if the error is already reported
|
||||
# or add if the error is new
|
||||
if not any(
|
||||
original_error.message == error.message
|
||||
and original_error.column == error.column
|
||||
for original_error in new_line_no_to_original_errors[error.line]
|
||||
):
|
||||
selected_errors.append(error)
|
||||
# 5.3. Error introduced by modified lines that impacted
|
||||
# the unchanged lines that have NO pre-existing errors
|
||||
else:
|
||||
selected_errors.append(error)
|
||||
|
||||
# 6. Sort errors by line and column
|
||||
selected_errors.sort(key=lambda x: (x.line, x.column))
|
||||
return selected_errors
|
||||
@@ -1,3 +0,0 @@
|
||||
from .cmd import check_tool_installed, run_cmd
|
||||
|
||||
__all__ = ['run_cmd', 'check_tool_installed']
|
||||
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_cmd(cmd: str, cwd: str | None = None) -> str | None:
|
||||
"""Run a command and return the output.
|
||||
|
||||
If the command succeeds, return None. If the command fails, return the stdout.
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd.split(),
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
)
|
||||
stdout, _ = process.communicate()
|
||||
if process.returncode == 0:
|
||||
return None
|
||||
return stdout
|
||||
|
||||
|
||||
def check_tool_installed(tool_name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[tool_name, '--version'],
|
||||
check=True,
|
||||
cwd=os.getcwd(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
@@ -307,6 +307,7 @@ def convert_tools_to_description(tools: list[dict]) -> str:
|
||||
def convert_fncall_messages_to_non_fncall_messages(
|
||||
messages: list[dict],
|
||||
tools: list[ChatCompletionToolParam],
|
||||
add_in_context_learning_example: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Convert function calling messages to non-function calling messages."""
|
||||
messages = copy.deepcopy(messages)
|
||||
@@ -319,7 +320,8 @@ def convert_fncall_messages_to_non_fncall_messages(
|
||||
converted_messages = []
|
||||
first_user_message_encountered = False
|
||||
for message in messages:
|
||||
role, content = message['role'], message['content']
|
||||
role = message['role']
|
||||
content = message.get('content')
|
||||
if content is None:
|
||||
content = ''
|
||||
|
||||
@@ -341,7 +343,7 @@ def convert_fncall_messages_to_non_fncall_messages(
|
||||
# 2. USER MESSAGES (no change)
|
||||
elif role == 'user':
|
||||
# Add in-context learning example for the first user message
|
||||
if not first_user_message_encountered:
|
||||
if not first_user_message_encountered and add_in_context_learning_example:
|
||||
first_user_message_encountered = True
|
||||
# Check tools
|
||||
if not (
|
||||
@@ -572,8 +574,10 @@ def convert_non_fncall_messages_to_fncall_messages(
|
||||
|
||||
first_user_message_encountered = False
|
||||
for message in messages:
|
||||
role, content = message['role'], message['content']
|
||||
content = content or '' # handle cases where content is None
|
||||
role = message['role']
|
||||
content = (
|
||||
message.get('content') or ''
|
||||
) # handle cases where content is None or missing
|
||||
# For system messages, remove the added suffix
|
||||
if role == 'system':
|
||||
if isinstance(content, str):
|
||||
@@ -751,13 +755,17 @@ def convert_non_fncall_messages_to_fncall_messages(
|
||||
|
||||
def convert_from_multiple_tool_calls_to_single_tool_call_messages(
|
||||
messages: list[dict],
|
||||
ignore_final_tool_result: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Break one message with multiple tool calls into multiple messages."""
|
||||
converted_messages = []
|
||||
|
||||
pending_tool_calls: dict[str, dict] = {}
|
||||
for message in messages:
|
||||
role, content = message['role'], message['content']
|
||||
role = message['role']
|
||||
content = message.get(
|
||||
'content'
|
||||
) # Don't set default here as we need to handle function calls
|
||||
if role == 'assistant':
|
||||
if message.get('tool_calls') and len(message['tool_calls']) > 1:
|
||||
# handle multiple tool calls by breaking them into multiple messages
|
||||
@@ -787,7 +795,7 @@ def convert_from_multiple_tool_calls_to_single_tool_call_messages(
|
||||
), f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}'
|
||||
converted_messages.append(message)
|
||||
|
||||
if len(pending_tool_calls) > 0:
|
||||
if not ignore_final_tool_result and len(pending_tool_calls) > 0:
|
||||
raise FunctionCallConversionError(
|
||||
f'Found pending tool calls but no tool result: {pending_tool_calls=}'
|
||||
)
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -16,16 +20,24 @@ permissions:
|
||||
jobs:
|
||||
call-openhands-resolver:
|
||||
if: |
|
||||
${{
|
||||
github.event.label.name == 'fix-me' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
startsWith(github.event.comment.body, vars.OPENHANDS_MACRO || '@openhands-agent') &&
|
||||
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER'))
|
||||
}}
|
||||
github.event.label.name == 'fix-me' ||
|
||||
|
||||
(
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
(startsWith(github.event.comment.body, inputs.macro || '@openhands-agent') || startsWith(github.event.comment.body, inputs.macro || vars.OPENHANDS_MACRO)) &&
|
||||
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
|
||||
) ||
|
||||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
(startsWith(github.event.review.body, inputs.macro || '@openhands-agent') || startsWith(github.event.review.body, inputs.macro || vars.OPENHANDS_MACRO)) &&
|
||||
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
|
||||
)
|
||||
)
|
||||
|
||||
uses: All-Hands-AI/OpenHands/.github/workflows/openhands-resolver.yml@main
|
||||
with:
|
||||
macro: ${{ vars.OPENHANDS_MACRO || '@openhands-agent' }}
|
||||
max_iterations: 50
|
||||
max_iterations: ${{ vars.OPENHANDS_MAX_ITER || 50 }}
|
||||
secrets:
|
||||
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
|
||||
@@ -18,7 +18,9 @@ class IssueHandlerInterface(ABC):
|
||||
issue_type: ClassVar[str]
|
||||
|
||||
@abstractmethod
|
||||
def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[GithubIssue]:
|
||||
"""Download issues from GitHub."""
|
||||
pass
|
||||
|
||||
@@ -83,7 +85,21 @@ class IssueHandler(IssueHandlerInterface):
|
||||
return re.findall(image_pattern, issue_body)
|
||||
|
||||
def _extract_issue_references(self, body: str) -> list[int]:
|
||||
pattern = r'#(\d+)'
|
||||
# First, remove code blocks as they may contain false positives
|
||||
body = re.sub(r'```.*?```', '', body, flags=re.DOTALL)
|
||||
|
||||
# Remove inline code
|
||||
body = re.sub(r'`[^`]*`', '', body)
|
||||
|
||||
# Remove URLs that contain hash symbols
|
||||
body = re.sub(r'https?://[^\s)]*#\d+[^\s)]*', '', body)
|
||||
|
||||
# Now extract issue numbers, making sure they're not part of other text
|
||||
# The pattern matches #number that:
|
||||
# 1. Is at the start of text or after whitespace/punctuation
|
||||
# 2. Is followed by whitespace, punctuation, or end of text
|
||||
# 3. Is not part of a URL
|
||||
pattern = r'(?:^|[\s\[({]|[^\w#])#(\d+)(?=[\s,.\])}]|$)'
|
||||
return [int(match) for match in re.findall(pattern, body)]
|
||||
|
||||
def _get_issue_comments(
|
||||
@@ -124,13 +140,29 @@ class IssueHandler(IssueHandlerInterface):
|
||||
|
||||
return all_comments if all_comments else None
|
||||
|
||||
def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[GithubIssue]:
|
||||
"""Download issues from Github.
|
||||
|
||||
Returns:
|
||||
List of Github issues.
|
||||
"""
|
||||
|
||||
if not issue_numbers:
|
||||
raise ValueError('Unspecified issue number')
|
||||
|
||||
all_issues = self._download_issues_from_github()
|
||||
logger.info(f'Limiting resolving to issues {issue_numbers}.')
|
||||
all_issues = [
|
||||
issue
|
||||
for issue in all_issues
|
||||
if issue['number'] in issue_numbers and 'pull_request' not in issue
|
||||
]
|
||||
|
||||
if len(issue_numbers) == 1 and not all_issues:
|
||||
raise ValueError(f'Issue {issue_numbers[0]} not found')
|
||||
|
||||
converted_issues = []
|
||||
for issue in all_issues:
|
||||
if any([issue.get(key) is None for key in ['number', 'title', 'body']]):
|
||||
@@ -139,9 +171,6 @@ class IssueHandler(IssueHandlerInterface):
|
||||
)
|
||||
continue
|
||||
|
||||
if 'pull_request' in issue:
|
||||
continue
|
||||
|
||||
# Get issue thread comments
|
||||
thread_comments = self._get_issue_comments(
|
||||
issue['number'], comment_id=comment_id
|
||||
@@ -455,22 +484,33 @@ class PRHandler(IssueHandler):
|
||||
)
|
||||
|
||||
for issue_number in unique_issue_references:
|
||||
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
issue_data = response.json()
|
||||
issue_body = issue_data.get('body', '')
|
||||
if issue_body:
|
||||
closing_issues.append(issue_body)
|
||||
try:
|
||||
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
issue_data = response.json()
|
||||
issue_body = issue_data.get('body', '')
|
||||
if issue_body:
|
||||
closing_issues.append(issue_body)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}')
|
||||
|
||||
return closing_issues
|
||||
|
||||
def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[GithubIssue]:
|
||||
if not issue_numbers:
|
||||
raise ValueError('Unspecified issue numbers')
|
||||
|
||||
all_issues = self._download_issues_from_github()
|
||||
logger.info(f'Limiting resolving to issues {issue_numbers}.')
|
||||
all_issues = [issue for issue in all_issues if issue['number'] in issue_numbers]
|
||||
|
||||
converted_issues = []
|
||||
for issue in all_issues:
|
||||
# For PRs, body can be None
|
||||
@@ -559,9 +599,7 @@ class PRHandler(IssueHandler):
|
||||
# Format thread comments if they exist
|
||||
thread_context = ''
|
||||
if issue.thread_comments:
|
||||
thread_context = '\n\nPR Thread Comments:\n' + '\n---\n'.join(
|
||||
issue.thread_comments
|
||||
)
|
||||
thread_context = '\n---\n'.join(issue.thread_comments)
|
||||
images.extend(self._extract_image_urls(thread_context))
|
||||
|
||||
instruction = template.render(
|
||||
|
||||
@@ -3,7 +3,7 @@ The feedback may be addressed to specific code files. In this case the file loca
|
||||
Please update the code based on the feedback for the repository in /workspace.
|
||||
An environment has been set up for you to start working. You may assume all necessary tools are installed.
|
||||
|
||||
# Issues addressed
|
||||
# Issues addressed
|
||||
{{ issues }}
|
||||
|
||||
# Review comments
|
||||
@@ -15,10 +15,13 @@ An environment has been set up for you to start working. You may assume all nece
|
||||
# Review thread files
|
||||
{{ files }}
|
||||
|
||||
# PR Thread Comments
|
||||
{{ thread_context }}
|
||||
|
||||
IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.
|
||||
You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instruction %}
|
||||
|
||||
Some basic information about this repository:
|
||||
{{ repo_instruction }}{% endif %}
|
||||
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
|
||||
@@ -83,11 +83,10 @@ async def resolve_issues(
|
||||
issue_handler = issue_handler_factory(issue_type, owner, repo, token)
|
||||
|
||||
# Load dataset
|
||||
issues: list[GithubIssue] = issue_handler.get_converted_issues()
|
||||
issues: list[GithubIssue] = issue_handler.get_converted_issues(
|
||||
issue_numbers=issue_numbers
|
||||
)
|
||||
|
||||
if issue_numbers is not None:
|
||||
issues = [issue for issue in issues if issue.number in issue_numbers]
|
||||
logger.info(f'Limiting resolving to issues {issue_numbers}.')
|
||||
if limit_issues is not None:
|
||||
issues = issues[:limit_issues]
|
||||
logger.info(f'Limiting resolving to first {limit_issues} issues.')
|
||||
|
||||
@@ -199,7 +199,7 @@ async def process_issue(
|
||||
)
|
||||
config.set_llm_config(llm_config)
|
||||
|
||||
runtime = create_runtime(config, sid=f'{issue.number}')
|
||||
runtime = create_runtime(config)
|
||||
await runtime.connect()
|
||||
|
||||
async def on_event(evt):
|
||||
@@ -339,13 +339,10 @@ async def resolve_issue(
|
||||
|
||||
# Load dataset
|
||||
issues: list[GithubIssue] = issue_handler.get_converted_issues(
|
||||
comment_id=comment_id
|
||||
issue_numbers=[issue_number], comment_id=comment_id
|
||||
)
|
||||
|
||||
# Find the specific issue
|
||||
issue = next((i for i in issues if i.number == issue_number), None)
|
||||
if not issue:
|
||||
raise ValueError(f'Issue {issue_number} not found')
|
||||
issue = issues[0]
|
||||
|
||||
if comment_id is not None:
|
||||
if (
|
||||
|
||||
@@ -203,6 +203,7 @@ def send_pull_request(
|
||||
pr_type: str,
|
||||
fork_owner: str | None = None,
|
||||
additional_message: str | None = None,
|
||||
target_branch: str | None = None,
|
||||
) -> str:
|
||||
if pr_type not in ['branch', 'draft', 'ready']:
|
||||
raise ValueError(f'Invalid pr_type: {pr_type}')
|
||||
@@ -224,12 +225,19 @@ def send_pull_request(
|
||||
attempt += 1
|
||||
branch_name = f'{base_branch_name}-try{attempt}'
|
||||
|
||||
# Get the default branch
|
||||
print('Getting default branch...')
|
||||
response = requests.get(f'{base_url}', headers=headers)
|
||||
response.raise_for_status()
|
||||
default_branch = response.json()['default_branch']
|
||||
print(f'Default branch: {default_branch}')
|
||||
# Get the default branch or use specified target branch
|
||||
print('Getting base branch...')
|
||||
if target_branch:
|
||||
base_branch = target_branch
|
||||
# Verify the target branch exists
|
||||
response = requests.get(f'{base_url}/branches/{target_branch}', headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f'Target branch {target_branch} does not exist')
|
||||
else:
|
||||
response = requests.get(f'{base_url}', headers=headers)
|
||||
response.raise_for_status()
|
||||
base_branch = response.json()['default_branch']
|
||||
print(f'Base branch: {base_branch}')
|
||||
|
||||
# Create and checkout the new branch
|
||||
print('Creating new branch...')
|
||||
@@ -279,7 +287,7 @@ def send_pull_request(
|
||||
'title': pr_title, # No need to escape title for GitHub API
|
||||
'body': pr_body,
|
||||
'head': branch_name,
|
||||
'base': default_branch,
|
||||
'base': base_branch,
|
||||
'draft': pr_type == 'draft',
|
||||
}
|
||||
response = requests.post(f'{base_url}/pulls', headers=headers, json=data)
|
||||
@@ -435,6 +443,7 @@ def process_single_issue(
|
||||
llm_config: LLMConfig,
|
||||
fork_owner: str | None,
|
||||
send_on_failure: bool,
|
||||
target_branch: str | None = None,
|
||||
) -> None:
|
||||
if not resolver_output.success and not send_on_failure:
|
||||
print(
|
||||
@@ -484,6 +493,7 @@ def process_single_issue(
|
||||
llm_config=llm_config,
|
||||
fork_owner=fork_owner,
|
||||
additional_message=resolver_output.success_explanation,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
|
||||
@@ -508,6 +518,7 @@ def process_all_successful_issues(
|
||||
llm_config,
|
||||
fork_owner,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -573,6 +584,12 @@ def main():
|
||||
default=None,
|
||||
help='Base URL for the LLM model.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--target-branch',
|
||||
type=str,
|
||||
default=None,
|
||||
help='Target branch to create the pull request against (defaults to repository default branch)',
|
||||
)
|
||||
my_args = parser.parse_args()
|
||||
|
||||
github_token = (
|
||||
@@ -625,6 +642,7 @@ def main():
|
||||
llm_config,
|
||||
my_args.fork_owner,
|
||||
my_args.send_on_failure,
|
||||
my_args.target_branch,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.async_utils import wait_all
|
||||
from openhands.utils.async_utils import call_sync_from_async, wait_all
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
@@ -170,7 +170,8 @@ class ActionExecutor:
|
||||
async def run(
|
||||
self, action: CmdRunAction
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
return self.bash_session.run(action)
|
||||
obs = await call_sync_from_async(self.bash_session.run, action)
|
||||
return obs
|
||||
|
||||
async def run_ipython(self, action: IPythonRunCellAction) -> Observation:
|
||||
if 'jupyter' in self.plugins:
|
||||
|
||||
@@ -47,11 +47,19 @@ STATUS_MESSAGES = {
|
||||
}
|
||||
|
||||
|
||||
class RuntimeNotReadyError(Exception):
|
||||
class RuntimeUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeDisconnectedError(Exception):
|
||||
class RuntimeNotReadyError(RuntimeUnavailableError):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeDisconnectedError(RuntimeUnavailableError):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeNotFoundError(RuntimeUnavailableError):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ from openhands.events.observation import (
|
||||
)
|
||||
from openhands.events.serialization import event_to_dict, observation_from_dict
|
||||
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.base import (
|
||||
Runtime,
|
||||
RuntimeDisconnectedError,
|
||||
RuntimeNotFoundError,
|
||||
)
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder
|
||||
from openhands.runtime.impl.eventstream.containers import remove_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
@@ -424,10 +428,22 @@ class EventStreamRuntime(Runtime):
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
reraise=(ConnectionRefusedError,),
|
||||
retry=tenacity.retry_if_exception_type(
|
||||
(ConnectionError, requests.exceptions.ConnectionError)
|
||||
),
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
if container.status == 'exited':
|
||||
raise RuntimeDisconnectedError(
|
||||
f'Container {self.container_name} has exited.'
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
raise RuntimeNotFoundError(f'Container {self.container_name} not found.')
|
||||
|
||||
self._refresh_logs()
|
||||
if not self.log_buffer:
|
||||
raise RuntimeError('Runtime client is not ready.')
|
||||
|
||||
@@ -31,6 +31,7 @@ from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.runtime.base import (
|
||||
Runtime,
|
||||
RuntimeDisconnectedError,
|
||||
RuntimeNotFoundError,
|
||||
RuntimeNotReadyError,
|
||||
)
|
||||
from openhands.runtime.builder.remote import RemoteRuntimeBuilder
|
||||
@@ -109,7 +110,9 @@ class RemoteRuntime(Runtime):
|
||||
if existing_runtime:
|
||||
self.log('debug', f'Using existing runtime with ID: {self.runtime_id}')
|
||||
elif self.attach_to_existing:
|
||||
raise RuntimeError('Could not find existing runtime to attach to.')
|
||||
raise RuntimeNotFoundError(
|
||||
f'Could not find existing runtime for SID: {self.sid}'
|
||||
)
|
||||
else:
|
||||
self.send_status_message('STATUS$STARTING_CONTAINER')
|
||||
if self.config.sandbox.runtime_container_image is None:
|
||||
|
||||
@@ -4,13 +4,11 @@ import re
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from openhands_aci.utils.diff import get_diff
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.action import FileEditAction, FileReadAction, FileWriteAction
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileEditObservation,
|
||||
@@ -22,7 +20,6 @@ from openhands.linter import DefaultLinter
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.utils.chunk_localizer import Chunk, get_top_k_chunk_matches
|
||||
from openhands.utils.diff import get_diff
|
||||
|
||||
SYS_MSG = """Your job is to produce a new version of the file based on the old version and the
|
||||
provided draft of the new version. The provided draft may be incomplete (it may skip lines) and/or incorrectly indented. You should try to apply the changes present in the draft to the old version, and output a new version of the file.
|
||||
|
||||
@@ -34,6 +34,7 @@ from fastapi import (
|
||||
Request,
|
||||
UploadFile,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
@@ -63,7 +64,12 @@ from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.llm import bedrock
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.auth.auth import get_sid_from_token, sign_token
|
||||
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
|
||||
from openhands.server.middleware import (
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
NoCacheMiddleware,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from openhands.server.session import SessionManager
|
||||
|
||||
load_dotenv()
|
||||
@@ -83,6 +89,15 @@ app.add_middleware(
|
||||
|
||||
|
||||
app.add_middleware(NoCacheMiddleware)
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware, rate_limiter=InMemoryRateLimiter(requests=10, seconds=1)
|
||||
)
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
async def health():
|
||||
return 'OK'
|
||||
|
||||
|
||||
security_scheme = HTTPBearer()
|
||||
|
||||
@@ -238,7 +253,8 @@ async def attach_session(request: Request, call_next):
|
||||
request.state.conversation = await session_manager.attach_to_conversation(
|
||||
request.state.sid
|
||||
)
|
||||
if request.state.conversation is None:
|
||||
if not request.state.conversation:
|
||||
logger.error(f'Runtime not found for session: {request.state.sid}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Session not found'},
|
||||
@@ -344,7 +360,13 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
|
||||
latest_event_id = -1
|
||||
if websocket.query_params.get('latest_event_id'):
|
||||
latest_event_id = int(websocket.query_params.get('latest_event_id'))
|
||||
try:
|
||||
latest_event_id = int(websocket.query_params.get('latest_event_id'))
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f'Invalid latest_event_id: {websocket.query_params.get("latest_event_id")}'
|
||||
)
|
||||
pass
|
||||
|
||||
async_stream = AsyncEventStreamWrapper(
|
||||
session.agent_session.event_stream, latest_event_id + 1
|
||||
@@ -361,7 +383,14 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
),
|
||||
):
|
||||
continue
|
||||
await websocket.send_json(event_to_dict(event))
|
||||
try:
|
||||
await websocket.send_json(event_to_dict(event))
|
||||
except WebSocketDisconnect:
|
||||
logger.warning(
|
||||
'Websocket disconnected while sending event history, before loop started'
|
||||
)
|
||||
session.close()
|
||||
return
|
||||
|
||||
await session.loop_recv()
|
||||
|
||||
@@ -564,6 +593,23 @@ def sanitize_filename(filename):
|
||||
return filename
|
||||
|
||||
|
||||
@app.get('/api/conversation')
|
||||
async def get_remote_runtime_config(request: Request):
|
||||
"""Retrieve the remote runtime configuration.
|
||||
|
||||
Currently, this is the runtime ID.
|
||||
"""
|
||||
runtime = request.state.conversation.runtime
|
||||
runtime_id = runtime.runtime_id if hasattr(runtime, 'runtime_id') else None
|
||||
session_id = runtime.sid if hasattr(runtime, 'sid') else None
|
||||
return JSONResponse(
|
||||
content={
|
||||
'runtime_id': runtime_id,
|
||||
'session_id': session_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/upload-files')
|
||||
async def upload_file(request: Request, files: list[UploadFile]):
|
||||
"""Upload a list of files to the workspace.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
@@ -41,3 +46,56 @@ class NoCacheMiddleware(BaseHTTPMiddleware):
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
|
||||
class InMemoryRateLimiter:
|
||||
history: dict
|
||||
requests: int
|
||||
seconds: int
|
||||
sleep_seconds: int
|
||||
|
||||
def __init__(self, requests: int = 2, seconds: int = 1, sleep_seconds: int = 1):
|
||||
self.requests = requests
|
||||
self.seconds = seconds
|
||||
self.sleep_seconds = sleep_seconds
|
||||
self.history = defaultdict(list)
|
||||
|
||||
def _clean_old_requests(self, key: str) -> None:
|
||||
now = datetime.now()
|
||||
cutoff = now - timedelta(seconds=self.seconds)
|
||||
self.history[key] = [ts for ts in self.history[key] if ts > cutoff]
|
||||
|
||||
async def __call__(self, request: Request) -> bool:
|
||||
key = request.client.host
|
||||
now = datetime.now()
|
||||
|
||||
self._clean_old_requests(key)
|
||||
|
||||
self.history[key].append(now)
|
||||
|
||||
if len(self.history[key]) > self.requests * 2:
|
||||
return False
|
||||
elif len(self.history[key]) > self.requests:
|
||||
if self.sleep_seconds > 0:
|
||||
await asyncio.sleep(self.sleep_seconds)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app: ASGIApp, rate_limiter: InMemoryRateLimiter):
|
||||
super().__init__(app)
|
||||
self.rate_limiter = rate_limiter
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
ok = await self.rate_limiter(request)
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={'message': 'Too many requests'},
|
||||
headers={'Retry-After': '1'},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -11,7 +11,7 @@ from openhands.events.action.agent import ChangeAgentStateAction
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.base import Runtime, RuntimeUnavailableError
|
||||
from openhands.security import SecurityAnalyzer, options
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
@@ -194,13 +194,13 @@ class AgentSession:
|
||||
|
||||
try:
|
||||
await self.runtime.connect()
|
||||
except Exception as e:
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Runtime initialization failed: {e}', exc_info=True)
|
||||
if self._status_callback:
|
||||
self._status_callback(
|
||||
'error', 'STATUS$ERROR_RUNTIME_DISCONNECTED', str(e)
|
||||
)
|
||||
raise
|
||||
return
|
||||
|
||||
if self.runtime is not None:
|
||||
logger.debug(
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import WebSocket
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.stream import session_exists
|
||||
from openhands.runtime.base import RuntimeUnavailableError
|
||||
from openhands.server.session.conversation import Conversation
|
||||
from openhands.server.session.session import Session
|
||||
from openhands.storage.files import FileStore
|
||||
@@ -26,7 +27,11 @@ class SessionManager:
|
||||
if not await session_exists(sid, self.file_store):
|
||||
return None
|
||||
c = Conversation(sid, file_store=self.file_store, config=self.config)
|
||||
await c.connect()
|
||||
try:
|
||||
await c.connect()
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Error connecting to conversation {c.sid}: {e}')
|
||||
return None
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import difflib
|
||||
|
||||
import whatthepatch
|
||||
|
||||
|
||||
def get_diff(old_contents: str, new_contents: str, filepath: str = 'file') -> str:
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
old_contents.split('\n'),
|
||||
new_contents.split('\n'),
|
||||
fromfile=filepath,
|
||||
tofile=filepath,
|
||||
# do not output unchange lines
|
||||
# because they can cause `parse_diff` to fail
|
||||
n=0,
|
||||
)
|
||||
)
|
||||
return '\n'.join(map(lambda x: x.rstrip(), diff))
|
||||
|
||||
|
||||
def parse_diff(diff_patch: str) -> list[whatthepatch.patch.Change]:
|
||||
# handle empty patch
|
||||
if diff_patch.strip() == '':
|
||||
return []
|
||||
|
||||
patch = whatthepatch.parse_patch(diff_patch)
|
||||
patch_list = list(patch)
|
||||
assert len(patch_list) == 1, (
|
||||
'parse_diff only supports single file diff. But got:\nPATCH:\n'
|
||||
+ diff_patch
|
||||
+ '\nPATCH LIST:\n'
|
||||
+ str(patch_list)
|
||||
)
|
||||
changes = patch_list[0].changes
|
||||
|
||||
# ignore changes that are the same (i.e., old_lineno == new_lineno)
|
||||
output_changes = []
|
||||
for change in changes:
|
||||
if change.old != change.new:
|
||||
output_changes.append(change)
|
||||
return output_changes
|
||||
13
poetry.lock
generated
13
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aenum"
|
||||
@@ -5629,7 +5629,6 @@ optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"},
|
||||
@@ -5642,17 +5641,18 @@ numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
files = [
|
||||
{file = "openhands_aci-0.1.0-py3-none-any.whl", hash = "sha256:f28e5a32e394d1e643f79bf8af27fe44d039cb71729d590f9f3ee0c23c075f00"},
|
||||
{file = "openhands_aci-0.1.0.tar.gz", hash = "sha256:babc55f516efbb27eb7e528662e14b75c902965c48a110408fda824b83ea4461"},
|
||||
{file = "openhands_aci-0.1.1-py3-none-any.whl", hash = "sha256:8831f97b887571005dca0d70a9f6f0a4f9feb35d3d41f499e70d72b5fb68a599"},
|
||||
{file = "openhands_aci-0.1.1.tar.gz", hash = "sha256:705b74a12a8f428e64295b5de125f553500f62ef5ab3a5a6284d8fcf638025e6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
diskcache = ">=5.6.3,<6.0.0"
|
||||
flake8 = "*"
|
||||
gitpython = "*"
|
||||
grep-ast = "0.3.3"
|
||||
litellm = "*"
|
||||
@@ -5661,6 +5661,7 @@ numpy = "*"
|
||||
pandas = "*"
|
||||
scipy = "*"
|
||||
tree-sitter = "0.21.3"
|
||||
whatthepatch = ">=1.0.6,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
@@ -10211,4 +10212,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "8718ffe2ed836fca6c646c37bdad2c9c8e63ebd7ec881f420148fef5095d19e4"
|
||||
content-hash = "b710448cff0788b563f4d7614fca438ab0b9fe19903a061750012c56da95ff37"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.13.1"
|
||||
version = "0.14.1"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
@@ -63,7 +63,7 @@ opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = "^0.64.145"
|
||||
runloop-api-client = "0.7.0"
|
||||
pygithub = "^2.5.0"
|
||||
openhands-aci = "^0.1.0"
|
||||
openhands-aci = "^0.1.1"
|
||||
|
||||
[tool.poetry.group.llama-index.dependencies]
|
||||
llama-index = "*"
|
||||
@@ -95,7 +95,6 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@@ -126,7 +125,6 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
TEST_IN_CI,
|
||||
_close_test_runtime,
|
||||
_load_runtime,
|
||||
)
|
||||
from conftest import TEST_IN_CI, _close_test_runtime, _load_runtime
|
||||
from openhands_aci.utils.diff import get_diff
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import FileEditAction, FileReadAction
|
||||
from openhands.events.observation import FileEditObservation
|
||||
from openhands.utils.diff import get_diff
|
||||
|
||||
ORGINAL = """from flask import Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def syntax_error_py_file(tmp_path):
|
||||
file_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wrongly_indented_py_file(tmp_path):
|
||||
file_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_py_file(tmp_path):
|
||||
file_content = 'print("Hello, World!")\n'
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_py_func_def(tmp_path):
|
||||
file_content = """def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_ruby_file(tmp_path):
|
||||
file_content = """def foo
|
||||
puts "Hello, World!"
|
||||
end
|
||||
foo
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.rb'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_incorrect_ruby_file(tmp_path):
|
||||
file_content = """def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.rb'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parenthesis_incorrect_ruby_file(tmp_path):
|
||||
file_content = """def print_hello_world()\n puts 'Hello World'\n"""
|
||||
file_path = tmp_path / 'test_file.rb'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
@@ -1,417 +0,0 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.utils.diff import get_diff, parse_diff
|
||||
|
||||
OLD_CONTENT = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE
|
||||
foo()
|
||||
"""
|
||||
|
||||
NEW_CONTENT_V1 = (
|
||||
OLD_CONTENT
|
||||
+ """
|
||||
def new_function_that_causes_error():
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
"""
|
||||
)
|
||||
|
||||
NEW_CONTENT_V2 = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
foo()
|
||||
"""
|
||||
|
||||
|
||||
def test_get_and_parse_diff(tmp_path):
|
||||
diff = get_diff(OLD_CONTENT, NEW_CONTENT_V1, 'test.py')
|
||||
print(diff)
|
||||
assert (
|
||||
diff
|
||||
== """
|
||||
--- test.py
|
||||
+++ test.py
|
||||
@@ -6,0 +7,3 @@
|
||||
+def new_function_that_causes_error():
|
||||
+ y = ANOTHER_UNDEFINED_VARIABLE
|
||||
+
|
||||
""".strip()
|
||||
)
|
||||
|
||||
print(
|
||||
'\n'.join(
|
||||
[f'{i+1}|{line}' for i, line in enumerate(NEW_CONTENT_V1.splitlines())]
|
||||
)
|
||||
)
|
||||
changes = parse_diff(diff)
|
||||
assert len(changes) == 3
|
||||
assert (
|
||||
changes[0].old is None
|
||||
and changes[0].new == 7
|
||||
and changes[0].line == 'def new_function_that_causes_error():'
|
||||
)
|
||||
assert (
|
||||
changes[1].old is None
|
||||
and changes[1].new == 8
|
||||
and changes[1].line == ' y = ANOTHER_UNDEFINED_VARIABLE'
|
||||
)
|
||||
assert changes[2].old is None and changes[2].new == 9 and changes[2].line == ''
|
||||
|
||||
|
||||
def test_lint_with_diff_append(tmp_path):
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(OLD_CONTENT)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(NEW_CONTENT_V1)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
print(result)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 8
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_diff_insert(tmp_path):
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(OLD_CONTENT)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(NEW_CONTENT_V2)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 5
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_multiple_changes_and_errors(tmp_path):
|
||||
old_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = 10
|
||||
foo()
|
||||
"""
|
||||
new_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE
|
||||
y = 20
|
||||
|
||||
def bar():
|
||||
z = ANOTHER_UNDEFINED_VARIABLE
|
||||
return z + 1
|
||||
|
||||
foo()
|
||||
bar()
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 4
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 8
|
||||
and result[1].column == 9
|
||||
and result[1].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_introduced_and_fixed_errors(tmp_path):
|
||||
old_content = """
|
||||
x = UNDEFINED_VARIABLE
|
||||
y = 10
|
||||
"""
|
||||
new_content = """
|
||||
x = 5
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
z = UNDEFINED_VARIABLE
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 3
|
||||
and result[0].column == 5
|
||||
and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 4
|
||||
and result[1].column == 5
|
||||
and result[1].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_multiline_changes(tmp_path):
|
||||
old_content = """
|
||||
def complex_function(a, b, c):
|
||||
return (a +
|
||||
b +
|
||||
c)
|
||||
"""
|
||||
new_content = """
|
||||
def complex_function(a, b, c):
|
||||
return (a +
|
||||
UNDEFINED_VARIABLE +
|
||||
b +
|
||||
c)
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 4
|
||||
and result[0].column == 13
|
||||
and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_syntax_error(tmp_path):
|
||||
old_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
"""
|
||||
new_content = """
|
||||
def foo():
|
||||
print("Hello, World!"
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 3
|
||||
and result[0].column == 11
|
||||
and result[0].message == "E999 SyntaxError: '(' was never closed"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_docstring_changes(tmp_path):
|
||||
old_content = '''
|
||||
def foo():
|
||||
"""This is a function."""
|
||||
print("Hello, World!")
|
||||
'''
|
||||
new_content = '''
|
||||
def foo():
|
||||
"""
|
||||
This is a function.
|
||||
It now has a multi-line docstring with an UNDEFINED_VARIABLE.
|
||||
"""
|
||||
print("Hello, World!")
|
||||
'''
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 0 # Linter should ignore changes in docstrings
|
||||
|
||||
|
||||
def test_lint_with_multiple_errors_on_same_line(tmp_path):
|
||||
old_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = 10
|
||||
foo()
|
||||
"""
|
||||
new_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE + ANOTHER_UNDEFINED_VARIABLE
|
||||
foo()
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
print(result)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 4
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 4
|
||||
and result[1].column == 30
|
||||
and result[1].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_diff_with_empty_patch():
|
||||
diff_patch = ''
|
||||
changes = parse_diff(diff_patch)
|
||||
assert len(changes) == 0
|
||||
|
||||
|
||||
def test_lint_file_diff_ignore_existing_errors(tmp_path):
|
||||
"""
|
||||
Make sure we allow edits as long as it does not introduce new errors. In other
|
||||
words, we don't care about existing linting errors. Although they might be
|
||||
real syntax issues, sometimes they are just false positives, or errors that
|
||||
we don't care about.
|
||||
"""
|
||||
content = """def some_valid_but_weird_function():
|
||||
# this function is legitimate, yet static analysis tools like flake8
|
||||
# reports 'F821 undefined name'
|
||||
if 'variable' in locals():
|
||||
print(variable)
|
||||
def some_wrong_but_unused_function():
|
||||
# this function has a linting error, but it is not modified by us, and
|
||||
# who knows, this function might be completely dead code
|
||||
x = 1
|
||||
def sum(a, b):
|
||||
return a - b
|
||||
"""
|
||||
new_content = content.replace(' return a - b', ' return a + b')
|
||||
temp_file_old_path = tmp_path / 'problematic-file-test.py'
|
||||
temp_file_old_path.write_text(content)
|
||||
temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
|
||||
temp_file_new_path.write_text(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(temp_file_old_path),
|
||||
str(temp_file_new_path),
|
||||
)
|
||||
assert len(result) == 0 # no new errors introduced
|
||||
|
||||
|
||||
def test_lint_file_diff_catch_new_errors_in_edits(tmp_path):
|
||||
"""
|
||||
Make sure we catch new linting errors in our edit chunk, and at the same
|
||||
time, ignore old linting errors (in this case, the old linting error is
|
||||
a false positive)
|
||||
"""
|
||||
content = """def some_valid_but_weird_function():
|
||||
# this function is legitimate, yet static analysis tools like flake8
|
||||
# reports 'F821 undefined name'
|
||||
if 'variable' in locals():
|
||||
print(variable)
|
||||
def sum(a, b):
|
||||
return a - b
|
||||
"""
|
||||
|
||||
temp_file_old_path = tmp_path / 'problematic-file-test.py'
|
||||
temp_file_old_path.write_text(content)
|
||||
new_content = content.replace(' return a - b', ' return a + variable')
|
||||
temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
|
||||
temp_file_new_path.write_text(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(temp_file_old_path),
|
||||
str(temp_file_new_path),
|
||||
)
|
||||
print(result)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 7
|
||||
and result[0].column == 16
|
||||
and result[0].message == "F821 undefined name 'variable'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_file_diff_catch_new_errors_outside_edits(tmp_path):
|
||||
"""
|
||||
Make sure we catch new linting errors induced by our edits, even
|
||||
though the error itself is not in the edit chunk
|
||||
"""
|
||||
content = """def valid_func1():
|
||||
print(my_sum(1, 2))
|
||||
def my_sum(a, b):
|
||||
return a - b
|
||||
def valid_func2():
|
||||
print(my_sum(0, 0))
|
||||
"""
|
||||
# Add 100 lines of invalid code, which linter shall ignore
|
||||
# because they are not being edited. For testing purpose, we
|
||||
# must add these existing linting errors, otherwise the pre-edit
|
||||
# linting would pass, and thus there won't be any comparison
|
||||
# between pre-edit and post-edit linting.
|
||||
for _ in range(100):
|
||||
content += '\ninvalid_func()'
|
||||
|
||||
temp_file_old_path = tmp_path / 'problematic-file-test.py'
|
||||
temp_file_old_path.write_text(content)
|
||||
|
||||
new_content = content.replace('def my_sum(a, b):', 'def my_sum2(a, b):')
|
||||
temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
|
||||
temp_file_new_path.write_text(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(temp_file_old_path),
|
||||
str(temp_file_new_path),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 2
|
||||
and result[0].column == 11
|
||||
and result[0].message == "F821 undefined name 'my_sum'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 6
|
||||
and result[1].column == 11
|
||||
and result[1].message == "F821 undefined name 'my_sum'"
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.languages.python import (
|
||||
PythonLinter,
|
||||
flake_lint,
|
||||
python_compile_lint,
|
||||
)
|
||||
|
||||
|
||||
def test_wrongly_indented_py_file(wrongly_indented_py_file):
|
||||
# Test Python linter
|
||||
linter = PythonLinter()
|
||||
assert '.py' in linter.supported_extensions
|
||||
result = linter.lint(wrongly_indented_py_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=wrongly_indented_py_file,
|
||||
line=2,
|
||||
column=5,
|
||||
message='E999 IndentationError: unexpected indent',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert result[0].visualize() == (
|
||||
'1|\n'
|
||||
'\033[91m2| def foo():\033[0m\n'
|
||||
' ^ ERROR HERE: E999 IndentationError: unexpected indent\n'
|
||||
'3| print("Hello, World!")\n'
|
||||
'4|'
|
||||
)
|
||||
|
||||
# General linter should have same result as Python linter
|
||||
# bc it uses PythonLinter under the hood
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(wrongly_indented_py_file)
|
||||
assert result == linter.lint(wrongly_indented_py_file)
|
||||
|
||||
# Test flake8_lint
|
||||
assert result == flake_lint(wrongly_indented_py_file)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(wrongly_indented_py_file)
|
||||
assert isinstance(compile_result, list) and len(compile_result) == 1
|
||||
assert compile_result[0] == LintResult(
|
||||
file=wrongly_indented_py_file, line=2, column=4, message='unexpected indent'
|
||||
)
|
||||
|
||||
|
||||
def test_simple_correct_py_file(simple_correct_py_file):
|
||||
linter = PythonLinter()
|
||||
assert '.py' in linter.supported_extensions
|
||||
result = linter.lint(simple_correct_py_file)
|
||||
assert result == []
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(simple_correct_py_file)
|
||||
assert result == linter.lint(simple_correct_py_file)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(simple_correct_py_file)
|
||||
assert compile_result == []
|
||||
|
||||
# Test flake_lint
|
||||
flake_result = flake_lint(simple_correct_py_file)
|
||||
assert flake_result == []
|
||||
|
||||
|
||||
def test_simple_correct_py_func_def(simple_correct_py_func_def):
|
||||
linter = PythonLinter()
|
||||
result = linter.lint(simple_correct_py_func_def)
|
||||
assert result == []
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(simple_correct_py_func_def)
|
||||
assert result == linter.lint(simple_correct_py_func_def)
|
||||
|
||||
# Test flake_lint
|
||||
assert result == flake_lint(simple_correct_py_func_def)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(simple_correct_py_func_def)
|
||||
assert compile_result == []
|
||||
@@ -1,113 +0,0 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.languages.treesitter import TreesitterBasicLinter
|
||||
|
||||
|
||||
def test_syntax_error_py_file(syntax_error_py_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(syntax_error_py_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=syntax_error_py_file,
|
||||
line=5,
|
||||
column=5,
|
||||
message='Syntax error',
|
||||
)
|
||||
|
||||
assert (
|
||||
result[0].visualize()
|
||||
== (
|
||||
'2| def foo():\n'
|
||||
'3| print("Hello, World!")\n'
|
||||
'4| print("Wrong indent")\n'
|
||||
'\033[91m5| foo(\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'6|'
|
||||
)
|
||||
)
|
||||
print(result[0].visualize())
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(syntax_error_py_file)
|
||||
# NOTE: general linter returns different result
|
||||
# because it uses flake8 first, which is different from treesitter
|
||||
assert general_result != result
|
||||
|
||||
|
||||
def test_simple_correct_ruby_file(simple_correct_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(simple_correct_ruby_file)
|
||||
assert isinstance(result, list) and len(result) == 0
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(simple_correct_ruby_file)
|
||||
assert general_result == result
|
||||
|
||||
|
||||
def test_simple_incorrect_ruby_file(simple_incorrect_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(simple_incorrect_ruby_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 2
|
||||
assert result[0] == LintResult(
|
||||
file=simple_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=1,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert (
|
||||
result[0].visualize()
|
||||
== (
|
||||
'\033[91m1|def foo():\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'2| print("Hello, World!")\n'
|
||||
'3|foo()'
|
||||
)
|
||||
)
|
||||
assert result[1] == LintResult(
|
||||
file=simple_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=10,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[1].visualize())
|
||||
assert (
|
||||
result[1].visualize()
|
||||
== (
|
||||
'\033[91m1|def foo():\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'2| print("Hello, World!")\n'
|
||||
'3|foo()'
|
||||
)
|
||||
)
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(simple_incorrect_ruby_file)
|
||||
assert general_result == result
|
||||
|
||||
|
||||
def test_parenthesis_incorrect_ruby_file(parenthesis_incorrect_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(parenthesis_incorrect_ruby_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=parenthesis_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=1,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert result[0].visualize() == (
|
||||
'\033[91m1|def print_hello_world()\033[0m\n'
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
"2| puts 'Hello World'"
|
||||
)
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(parenthesis_incorrect_ruby_file)
|
||||
assert general_result == result
|
||||
@@ -1,86 +0,0 @@
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.linter.base import LintResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_file_content():
|
||||
return '\n'.join([f'Line {i}' for i in range(1, 21)])
|
||||
|
||||
|
||||
def test_visualize_standard_case(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=10, column=5, message='Test error message'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=3)
|
||||
|
||||
expected_output = (
|
||||
" 7|Line 7\n"
|
||||
" 8|Line 8\n"
|
||||
" 9|Line 9\n"
|
||||
"\033[91m10|Line 10\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Test error message\n"
|
||||
"11|Line 11\n"
|
||||
"12|Line 12\n"
|
||||
"13|Line 13"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_small_window(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=10, column=5, message='Test error message'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=1)
|
||||
|
||||
expected_output = (
|
||||
" 9|Line 9\n"
|
||||
"\033[91m10|Line 10\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Test error message\n"
|
||||
"11|Line 11"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_error_at_start(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=1, column=3, message='Start error'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=2)
|
||||
|
||||
expected_output = (
|
||||
"\033[91m 1|Line 1\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Start error\n"
|
||||
" 2|Line 2\n"
|
||||
" 3|Line 3"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_error_at_end(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=20, column=1, message='End error'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=2)
|
||||
|
||||
expected_output = (
|
||||
"18|Line 18\n"
|
||||
"19|Line 19\n"
|
||||
"\033[91m20|Line 20\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: End error"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
@@ -1,17 +1,18 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
from openhands.resolver.issue_definitions import IssueHandler, PRHandler
|
||||
from openhands.resolver.github_issue import GithubIssue, ReviewThread
|
||||
from openhands.events.action.message import MessageAction
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.resolver.github_issue import GithubIssue, ReviewThread
|
||||
from openhands.resolver.issue_definitions import IssueHandler, PRHandler
|
||||
|
||||
|
||||
def test_get_converted_issues_initializes_review_comments():
|
||||
# Mock the necessary dependencies
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for issues
|
||||
mock_issues_response = MagicMock()
|
||||
mock_issues_response.json.return_value = [
|
||||
{"number": 1, "title": "Test Issue", "body": "Test Body"}
|
||||
{'number': 1, 'title': 'Test Issue', 'body': 'Test Body'}
|
||||
]
|
||||
# Mock the response for comments
|
||||
mock_comments_response = MagicMock()
|
||||
@@ -26,10 +27,10 @@ def test_get_converted_issues_initializes_review_comments():
|
||||
] # Need two comment responses because we make two API calls
|
||||
|
||||
# Create an instance of IssueHandler
|
||||
handler = IssueHandler("test-owner", "test-repo", "test-token")
|
||||
handler = IssueHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get converted issues
|
||||
issues = handler.get_converted_issues()
|
||||
issues = handler.get_converted_issues(issue_numbers=[1])
|
||||
|
||||
# Verify that we got exactly one issue
|
||||
assert len(issues) == 1
|
||||
@@ -39,35 +40,35 @@ def test_get_converted_issues_initializes_review_comments():
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert issues[0].number == 1
|
||||
assert issues[0].title == "Test Issue"
|
||||
assert issues[0].body == "Test Body"
|
||||
assert issues[0].owner == "test-owner"
|
||||
assert issues[0].repo == "test-repo"
|
||||
assert issues[0].title == 'Test Issue'
|
||||
assert issues[0].body == 'Test Body'
|
||||
assert issues[0].owner == 'test-owner'
|
||||
assert issues[0].repo == 'test-repo'
|
||||
|
||||
|
||||
def test_pr_handler_guess_success_with_thread_comments():
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Create a mock issue with thread comments but no review comments
|
||||
issue = GithubIssue(
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=["First comment", "Second comment"],
|
||||
closing_issues=["Issue description"],
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
thread_comments=['First comment', 'Second comment'],
|
||||
closing_issues=['Issue description'],
|
||||
review_comments=None,
|
||||
thread_ids=None,
|
||||
head_branch="test-branch",
|
||||
head_branch='test-branch',
|
||||
)
|
||||
|
||||
# Create mock history
|
||||
history = [MessageAction(content="Fixed the issue by implementing X and Y")]
|
||||
history = [MessageAction(content='Fixed the issue by implementing X and Y')]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -84,7 +85,7 @@ The changes successfully address the feedback."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch("litellm.completion", return_value=mock_response):
|
||||
with patch('litellm.completion', return_value=mock_response):
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
)
|
||||
@@ -92,39 +93,39 @@ The changes successfully address the feedback."""
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert success_list == [True]
|
||||
assert "successfully address" in explanation
|
||||
assert 'successfully address' in explanation
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_comments():
|
||||
# Mock the necessary dependencies
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body fixes #1",
|
||||
"head": {"ref": "test-branch"},
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body fixes #1',
|
||||
'head': {'ref': 'test-branch'},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{"body": "First comment"},
|
||||
{"body": "Second comment"},
|
||||
{'body': 'First comment'},
|
||||
{'body': 'Second comment'},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {"edges": []},
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {'edges': []},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +139,7 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
# Mock the response for fetching the external issue referenced in PR body
|
||||
mock_external_issue_response = MagicMock()
|
||||
mock_external_issue_response.json.return_value = {
|
||||
"body": "This is additional context from an externally referenced issue."
|
||||
'body': 'This is additional context from an externally referenced issue.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
@@ -150,56 +151,56 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch("requests.post") as mock_post:
|
||||
with patch('requests.post') as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues()
|
||||
prs = handler.get_converted_issues(issue_numbers=[1])
|
||||
|
||||
# Verify that we got exactly one PR
|
||||
assert len(prs) == 1
|
||||
|
||||
# Verify that thread_comments are set correctly
|
||||
assert prs[0].thread_comments == ["First comment", "Second comment"]
|
||||
assert prs[0].thread_comments == ['First comment', 'Second comment']
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body fixes #1"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body fixes #1'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].closing_issues == [
|
||||
"This is additional context from an externally referenced issue."
|
||||
'This is additional context from an externally referenced issue.'
|
||||
]
|
||||
|
||||
|
||||
def test_pr_handler_guess_success_only_review_comments():
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Create a mock issue with only review comments
|
||||
issue = GithubIssue(
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
thread_comments=None,
|
||||
closing_issues=["Issue description"],
|
||||
review_comments=["Please fix the formatting", "Add more tests"],
|
||||
closing_issues=['Issue description'],
|
||||
review_comments=['Please fix the formatting', 'Add more tests'],
|
||||
thread_ids=None,
|
||||
head_branch="test-branch",
|
||||
head_branch='test-branch',
|
||||
)
|
||||
|
||||
# Create mock history
|
||||
history = [MessageAction(content="Fixed the formatting and added more tests")]
|
||||
history = [MessageAction(content='Fixed the formatting and added more tests')]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -216,7 +217,7 @@ The changes successfully address the review comments."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch("litellm.completion", return_value=mock_response):
|
||||
with patch('litellm.completion', return_value=mock_response):
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
)
|
||||
@@ -224,32 +225,32 @@ The changes successfully address the review comments."""
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert success_list == [True]
|
||||
assert "successfully address" in explanation
|
||||
assert 'successfully address' in explanation
|
||||
|
||||
|
||||
def test_pr_handler_guess_success_no_comments():
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Create a mock issue with no comments
|
||||
issue = GithubIssue(
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
thread_comments=None,
|
||||
closing_issues=["Issue description"],
|
||||
closing_issues=['Issue description'],
|
||||
review_comments=None,
|
||||
thread_ids=None,
|
||||
head_branch="test-branch",
|
||||
head_branch='test-branch',
|
||||
)
|
||||
|
||||
# Create mock history
|
||||
history = [MessageAction(content="Fixed the issue")]
|
||||
history = [MessageAction(content='Fixed the issue')]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
|
||||
# Test that it returns appropriate message when no comments are present
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
@@ -257,29 +258,29 @@ def test_pr_handler_guess_success_no_comments():
|
||||
)
|
||||
assert success is False
|
||||
assert success_list is None
|
||||
assert explanation == "No feedback was found to process"
|
||||
assert explanation == 'No feedback was found to process'
|
||||
|
||||
|
||||
def test_get_issue_comments_with_specific_comment_id():
|
||||
# Mock the necessary dependencies
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{"id": 123, "body": "First comment"},
|
||||
{"id": 456, "body": "Second comment"},
|
||||
{'id': 123, 'body': 'First comment'},
|
||||
{'id': 456, 'body': 'Second comment'},
|
||||
]
|
||||
|
||||
mock_get.return_value = mock_comments_response
|
||||
|
||||
# Create an instance of IssueHandler
|
||||
handler = IssueHandler("test-owner", "test-repo", "test-token")
|
||||
handler = IssueHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get comments with a specific comment_id
|
||||
specific_comment = handler._get_issue_comments(issue_number=1, comment_id=123)
|
||||
|
||||
# Verify only the specific comment is returned
|
||||
assert specific_comment == ["First comment"]
|
||||
assert specific_comment == ['First comment']
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
@@ -287,50 +288,50 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
specific_comment_id = 123
|
||||
|
||||
# Mock GraphQL response for review threads
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body",
|
||||
"head": {"ref": "test-branch"},
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body',
|
||||
'head': {'ref': 'test-branch'},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{"body": "First comment", "id": 123},
|
||||
{"body": "Second comment", "id": 124},
|
||||
{'body': 'First comment', 'id': 123},
|
||||
{'body': 'Second comment', 'id': 124},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {
|
||||
"edges": [
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {
|
||||
'edges': [
|
||||
{
|
||||
"node": {
|
||||
"id": "review-thread-1",
|
||||
"isResolved": False,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
'node': {
|
||||
'id': 'review-thread-1',
|
||||
'isResolved': False,
|
||||
'comments': {
|
||||
'nodes': [
|
||||
{
|
||||
"fullDatabaseId": 121,
|
||||
"body": "Specific review comment",
|
||||
"path": "file1.txt",
|
||||
'fullDatabaseId': 121,
|
||||
'body': 'Specific review comment',
|
||||
'path': 'file1.txt',
|
||||
},
|
||||
{
|
||||
"fullDatabaseId": 456,
|
||||
"body": "Another review comment",
|
||||
"path": "file2.txt",
|
||||
'fullDatabaseId': 456,
|
||||
'body': 'Another review comment',
|
||||
'path': 'file2.txt',
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -356,30 +357,32 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch("requests.post") as mock_post:
|
||||
with patch('requests.post') as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues(comment_id=specific_comment_id)
|
||||
prs = handler.get_converted_issues(
|
||||
issue_numbers=[1], comment_id=specific_comment_id
|
||||
)
|
||||
|
||||
# Verify that we got exactly one PR
|
||||
assert len(prs) == 1
|
||||
|
||||
# Verify that thread_comments are set correctly
|
||||
assert prs[0].thread_comments == ["First comment"]
|
||||
assert prs[0].thread_comments == ['First comment']
|
||||
assert prs[0].review_comments == []
|
||||
assert prs[0].review_threads == []
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
@@ -387,50 +390,50 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
specific_comment_id = 123
|
||||
|
||||
# Mock GraphQL response for review threads
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body",
|
||||
"head": {"ref": "test-branch"},
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body',
|
||||
'head': {'ref': 'test-branch'},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{"body": "First comment", "id": 120},
|
||||
{"body": "Second comment", "id": 124},
|
||||
{'body': 'First comment', 'id': 120},
|
||||
{'body': 'Second comment', 'id': 124},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {
|
||||
"edges": [
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {
|
||||
'edges': [
|
||||
{
|
||||
"node": {
|
||||
"id": "review-thread-1",
|
||||
"isResolved": False,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
'node': {
|
||||
'id': 'review-thread-1',
|
||||
'isResolved': False,
|
||||
'comments': {
|
||||
'nodes': [
|
||||
{
|
||||
"fullDatabaseId": specific_comment_id,
|
||||
"body": "Specific review comment",
|
||||
"path": "file1.txt",
|
||||
'fullDatabaseId': specific_comment_id,
|
||||
'body': 'Specific review comment',
|
||||
'path': 'file1.txt',
|
||||
},
|
||||
{
|
||||
"fullDatabaseId": 456,
|
||||
"body": "Another review comment",
|
||||
"path": "file1.txt",
|
||||
'fullDatabaseId': 456,
|
||||
'body': 'Another review comment',
|
||||
'path': 'file1.txt',
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -456,14 +459,16 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch("requests.post") as mock_post:
|
||||
with patch('requests.post') as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues(comment_id=specific_comment_id)
|
||||
prs = handler.get_converted_issues(
|
||||
issue_numbers=[1], comment_id=specific_comment_id
|
||||
)
|
||||
|
||||
# Verify that we got exactly one PR
|
||||
assert len(prs) == 1
|
||||
@@ -475,17 +480,17 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
assert isinstance(prs[0].review_threads[0], ReviewThread)
|
||||
assert (
|
||||
prs[0].review_threads[0].comment
|
||||
== "Specific review comment\n---\nlatest feedback:\nAnother review comment\n"
|
||||
== 'Specific review comment\n---\nlatest feedback:\nAnother review comment\n'
|
||||
)
|
||||
assert prs[0].review_threads[0].files == ["file1.txt"]
|
||||
assert prs[0].review_threads[0].files == ['file1.txt']
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
@@ -493,50 +498,50 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
specific_comment_id = 123
|
||||
|
||||
# Mock GraphQL response for review threads
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test PR fixes #3",
|
||||
"body": "Test Body",
|
||||
"head": {"ref": "test-branch"},
|
||||
'number': 1,
|
||||
'title': 'Test PR fixes #3',
|
||||
'body': 'Test Body',
|
||||
'head': {'ref': 'test-branch'},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{"body": "First comment", "id": 120},
|
||||
{"body": "Second comment", "id": 124},
|
||||
{'body': 'First comment', 'id': 120},
|
||||
{'body': 'Second comment', 'id': 124},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {
|
||||
"edges": [
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {
|
||||
'edges': [
|
||||
{
|
||||
"node": {
|
||||
"id": "review-thread-1",
|
||||
"isResolved": False,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
'node': {
|
||||
'id': 'review-thread-1',
|
||||
'isResolved': False,
|
||||
'comments': {
|
||||
'nodes': [
|
||||
{
|
||||
"fullDatabaseId": specific_comment_id,
|
||||
"body": "Specific review comment that references #6",
|
||||
"path": "file1.txt",
|
||||
'fullDatabaseId': specific_comment_id,
|
||||
'body': 'Specific review comment that references #6',
|
||||
'path': 'file1.txt',
|
||||
},
|
||||
{
|
||||
"fullDatabaseId": 456,
|
||||
"body": "Another review comment referencing #7",
|
||||
"path": "file2.txt",
|
||||
'fullDatabaseId': 456,
|
||||
'body': 'Another review comment referencing #7',
|
||||
'path': 'file2.txt',
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -557,13 +562,13 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
# Mock the response for fetching the external issue referenced in PR body
|
||||
mock_external_issue_response_in_body = MagicMock()
|
||||
mock_external_issue_response_in_body.json.return_value = {
|
||||
"body": "External context #1."
|
||||
'body': 'External context #1.'
|
||||
}
|
||||
|
||||
# Mock the response for fetching the external issue referenced in review thread
|
||||
mock_external_issue_response_review_thread = MagicMock()
|
||||
mock_external_issue_response_review_thread.json.return_value = {
|
||||
"body": "External context #2."
|
||||
'body': 'External context #2.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
@@ -576,14 +581,16 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch("requests.post") as mock_post:
|
||||
with patch('requests.post') as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues(comment_id=specific_comment_id)
|
||||
prs = handler.get_converted_issues(
|
||||
issue_numbers=[1], comment_id=specific_comment_id
|
||||
)
|
||||
|
||||
# Verify that we got exactly one PR
|
||||
assert len(prs) == 1
|
||||
@@ -595,52 +602,52 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
assert isinstance(prs[0].review_threads[0], ReviewThread)
|
||||
assert (
|
||||
prs[0].review_threads[0].comment
|
||||
== "Specific review comment that references #6\n---\nlatest feedback:\nAnother review comment referencing #7\n"
|
||||
== 'Specific review comment that references #6\n---\nlatest feedback:\nAnother review comment referencing #7\n'
|
||||
)
|
||||
assert prs[0].closing_issues == [
|
||||
"External context #1.",
|
||||
"External context #2.",
|
||||
'External context #1.',
|
||||
'External context #2.',
|
||||
] # Only includes references inside comment ID and body PR
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == "Test PR fixes #3"
|
||||
assert prs[0].body == "Test Body"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].title == 'Test PR fixes #3'
|
||||
assert prs[0].body == 'Test Body'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
# Mock the necessary dependencies
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch('requests.get') as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body fixes #1",
|
||||
"head": {"ref": "test-branch"},
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body fixes #1',
|
||||
'head': {'ref': 'test-branch'},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{"body": "First comment addressing #1"},
|
||||
{"body": "Second comment addressing #2"},
|
||||
{'body': 'First comment addressing #1'},
|
||||
{'body': 'Second comment addressing #2'},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {"edges": []},
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {'edges': []},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,13 +661,13 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
# Mock the response for fetching the external issue referenced in PR body
|
||||
mock_external_issue_response_in_body = MagicMock()
|
||||
mock_external_issue_response_in_body.json.return_value = {
|
||||
"body": "External context #1."
|
||||
'body': 'External context #1.'
|
||||
}
|
||||
|
||||
# Mock the response for fetching the external issue referenced in review thread
|
||||
mock_external_issue_response_in_comment = MagicMock()
|
||||
mock_external_issue_response_in_comment.json.return_value = {
|
||||
"body": "External context #2."
|
||||
'body': 'External context #2.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
@@ -673,32 +680,32 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch("requests.post") as mock_post:
|
||||
with patch('requests.post') as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues()
|
||||
prs = handler.get_converted_issues(issue_numbers=[1])
|
||||
|
||||
# Verify that we got exactly one PR
|
||||
assert len(prs) == 1
|
||||
|
||||
# Verify that thread_comments are set correctly
|
||||
assert prs[0].thread_comments == [
|
||||
"First comment addressing #1",
|
||||
"Second comment addressing #2",
|
||||
'First comment addressing #1',
|
||||
'Second comment addressing #2',
|
||||
]
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body fixes #1"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body fixes #1'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].closing_issues == [
|
||||
"External context #1.",
|
||||
"External context #2.",
|
||||
'External context #1.',
|
||||
'External context #2.',
|
||||
]
|
||||
|
||||
94
tests/unit/resolver/test_issue_handler_error_handling.py
Normal file
94
tests/unit/resolver/test_issue_handler_error_handling.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from openhands.resolver.issue_definitions import PRHandler
|
||||
from openhands.resolver.github_issue import ReviewThread
|
||||
|
||||
|
||||
def test_handle_nonexistent_issue_reference():
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Mock the requests.get to simulate a 404 error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error: Not Found")
|
||||
|
||||
with patch('requests.get', return_value=mock_response):
|
||||
# Call the method with a non-existent issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body="This references #999999", # Non-existent issue
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None
|
||||
)
|
||||
|
||||
# The method should return an empty list since the referenced issue couldn't be fetched
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_handle_rate_limit_error():
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Mock the requests.get to simulate a rate limit error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
"403 Client Error: Rate Limit Exceeded"
|
||||
)
|
||||
|
||||
with patch('requests.get', return_value=mock_response):
|
||||
# Call the method with an issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body="This references #123",
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None
|
||||
)
|
||||
|
||||
# The method should return an empty list since the request was rate limited
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_handle_network_error():
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Mock the requests.get to simulate a network error
|
||||
with patch('requests.get', side_effect=requests.exceptions.ConnectionError("Network Error")):
|
||||
# Call the method with an issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body="This references #123",
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None
|
||||
)
|
||||
|
||||
# The method should return an empty list since the network request failed
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_successful_issue_reference():
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Mock a successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"body": "This is the referenced issue body"}
|
||||
|
||||
with patch('requests.get', return_value=mock_response):
|
||||
# Call the method with an issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body="This references #123",
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None
|
||||
)
|
||||
|
||||
# The method should return a list with the referenced issue body
|
||||
assert result == ["This is the referenced issue body"]
|
||||
34
tests/unit/resolver/test_issue_references.py
Normal file
34
tests/unit/resolver/test_issue_references.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from openhands.resolver.issue_definitions import IssueHandler
|
||||
|
||||
|
||||
def test_extract_issue_references():
|
||||
handler = IssueHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Test basic issue reference
|
||||
assert handler._extract_issue_references("Fixes #123") == [123]
|
||||
|
||||
# Test multiple issue references
|
||||
assert handler._extract_issue_references("Fixes #123, #456") == [123, 456]
|
||||
|
||||
# Test issue references in code blocks should be ignored
|
||||
assert handler._extract_issue_references("""
|
||||
Here's a code block:
|
||||
```python
|
||||
# This is a comment with #123
|
||||
def func():
|
||||
pass # Another #456
|
||||
```
|
||||
But this #789 should be extracted
|
||||
""") == [789]
|
||||
|
||||
# Test issue references in inline code should be ignored
|
||||
assert handler._extract_issue_references("This `#123` should be ignored but #456 should be extracted") == [456]
|
||||
|
||||
# Test issue references in URLs should be ignored
|
||||
assert handler._extract_issue_references("Check http://example.com/#123 but #456 should be extracted") == [456]
|
||||
|
||||
# Test issue references in markdown links should be extracted
|
||||
assert handler._extract_issue_references("[Link to #123](http://example.com) and #456") == [123, 456]
|
||||
|
||||
# Test issue references with text around them
|
||||
assert handler._extract_issue_references("Issue #123 is fixed and #456 is pending") == [123, 456]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -322,7 +322,17 @@ def test_update_existing_pull_request(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pr_type', ['branch', 'draft', 'ready'])
|
||||
@pytest.mark.parametrize(
|
||||
'pr_type,target_branch',
|
||||
[
|
||||
('branch', None),
|
||||
('draft', None),
|
||||
('ready', None),
|
||||
('branch', 'feature'),
|
||||
('draft', 'develop'),
|
||||
('ready', 'staging'),
|
||||
],
|
||||
)
|
||||
@patch('subprocess.run')
|
||||
@patch('requests.post')
|
||||
@patch('requests.get')
|
||||
@@ -334,14 +344,22 @@ def test_send_pull_request(
|
||||
mock_output_dir,
|
||||
mock_llm_config,
|
||||
pr_type,
|
||||
target_branch,
|
||||
):
|
||||
repo_path = os.path.join(mock_output_dir, 'repo')
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}),
|
||||
]
|
||||
# Mock API responses based on whether target_branch is specified
|
||||
if target_branch:
|
||||
mock_get.side_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=200), # Target branch exists
|
||||
]
|
||||
else:
|
||||
mock_get.side_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
]
|
||||
|
||||
mock_post.return_value.json.return_value = {
|
||||
'html_url': 'https://github.com/test-owner/test-repo/pull/1'
|
||||
}
|
||||
@@ -360,10 +378,12 @@ def test_send_pull_request(
|
||||
patch_dir=repo_path,
|
||||
pr_type=pr_type,
|
||||
llm_config=mock_llm_config,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
# Assert API calls
|
||||
assert mock_get.call_count == 2
|
||||
expected_get_calls = 2
|
||||
assert mock_get.call_count == expected_get_calls
|
||||
|
||||
# Check branch creation and push
|
||||
assert mock_run.call_count == 2
|
||||
@@ -401,10 +421,41 @@ def test_send_pull_request(
|
||||
assert post_data['title'] == 'Fix issue #42: Test Issue'
|
||||
assert post_data['body'].startswith('This pull request fixes #42.')
|
||||
assert post_data['head'] == 'openhands-fix-issue-42'
|
||||
assert post_data['base'] == 'main'
|
||||
assert post_data['base'] == (target_branch if target_branch else 'main')
|
||||
assert post_data['draft'] == (pr_type == 'draft')
|
||||
|
||||
|
||||
@patch('requests.get')
|
||||
def test_send_pull_request_invalid_target_branch(
|
||||
mock_get, mock_github_issue, mock_output_dir, mock_llm_config
|
||||
):
|
||||
"""Test that an error is raised when specifying a non-existent target branch"""
|
||||
repo_path = os.path.join(mock_output_dir, 'repo')
|
||||
|
||||
# Mock API response for non-existent branch
|
||||
mock_get.side_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=404), # Target branch doesn't exist
|
||||
]
|
||||
|
||||
# Test that ValueError is raised when target branch doesn't exist
|
||||
with pytest.raises(
|
||||
ValueError, match='Target branch nonexistent-branch does not exist'
|
||||
):
|
||||
send_pull_request(
|
||||
github_issue=mock_github_issue,
|
||||
github_token='test-token',
|
||||
github_username='test-user',
|
||||
patch_dir=repo_path,
|
||||
pr_type='ready',
|
||||
llm_config=mock_llm_config,
|
||||
target_branch='nonexistent-branch',
|
||||
)
|
||||
|
||||
# Verify API calls
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
@patch('subprocess.run')
|
||||
@patch('requests.post')
|
||||
@patch('requests.get')
|
||||
@@ -616,6 +667,7 @@ def test_process_single_pr_update(
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'pr', 'branch 1')
|
||||
@@ -688,6 +740,7 @@ def test_process_single_issue(
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
# Assert that the mocked functions were called with correct arguments
|
||||
@@ -704,9 +757,10 @@ def test_process_single_issue(
|
||||
github_username=github_username,
|
||||
patch_dir=f'{mock_output_dir}/patches/issue_1',
|
||||
pr_type=pr_type,
|
||||
llm_config=mock_llm_config,
|
||||
fork_owner=None,
|
||||
additional_message=resolver_output.success_explanation,
|
||||
llm_config=mock_llm_config,
|
||||
target_branch=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -757,6 +811,7 @@ def test_process_single_issue_unsuccessful(
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
# Assert that none of the mocked functions were called
|
||||
@@ -863,6 +918,7 @@ def test_process_all_successful_issues(
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
None,
|
||||
),
|
||||
call(
|
||||
'output_dir',
|
||||
@@ -873,6 +929,7 @@ def test_process_all_successful_issues(
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
None,
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -971,6 +1028,7 @@ def test_main(
|
||||
mock_args.llm_model = 'mock_model'
|
||||
mock_args.llm_base_url = 'mock_url'
|
||||
mock_args.llm_api_key = 'mock_key'
|
||||
mock_args.target_branch = None
|
||||
mock_parser.return_value.parse_args.return_value = mock_args
|
||||
|
||||
# Setup environment variables
|
||||
@@ -994,12 +1052,8 @@ def test_main(
|
||||
api_key=mock_args.llm_api_key,
|
||||
)
|
||||
|
||||
# Assert function calls
|
||||
mock_parser.assert_called_once()
|
||||
mock_getenv.assert_any_call('GITHUB_TOKEN')
|
||||
mock_path_exists.assert_called_with('/mock/output')
|
||||
mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)
|
||||
mock_process_single_issue.assert_called_with(
|
||||
# Use any_call instead of assert_called_with for more flexible matching
|
||||
assert mock_process_single_issue.call_args == call(
|
||||
'/mock/output',
|
||||
mock_resolver_output,
|
||||
'mock_token',
|
||||
@@ -1008,8 +1062,15 @@ def test_main(
|
||||
llm_config,
|
||||
None,
|
||||
False,
|
||||
mock_args.target_branch,
|
||||
)
|
||||
|
||||
# Other assertions
|
||||
mock_parser.assert_called_once()
|
||||
mock_getenv.assert_any_call('GITHUB_TOKEN')
|
||||
mock_path_exists.assert_called_with('/mock/output')
|
||||
mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)
|
||||
|
||||
# Test for 'all_successful' issue number
|
||||
mock_args.issue_number = 'all_successful'
|
||||
main()
|
||||
|
||||
33
tests/unit/test_fn_call_converter.py
Normal file
33
tests/unit/test_fn_call_converter.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from openhands.llm.fn_call_converter import (
|
||||
convert_fncall_messages_to_non_fncall_messages,
|
||||
)
|
||||
|
||||
|
||||
def test_convert_fncall_messages_no_content():
|
||||
"""Test that messages without content are handled correctly."""
|
||||
messages = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'},
|
||||
{'role': 'user', 'content': 'Hello!'},
|
||||
{
|
||||
'role': 'assistant',
|
||||
'function_call': {'name': 'greet', 'arguments': '{}'},
|
||||
'role': 'assistant',
|
||||
}, # No content
|
||||
]
|
||||
tools = [
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'greet',
|
||||
'description': 'Greet the user',
|
||||
'parameters': {'type': 'object', 'properties': {}, 'required': []},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# This should not raise a KeyError
|
||||
result = convert_fncall_messages_to_non_fncall_messages(
|
||||
messages, tools, add_in_context_learning_example=False
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == len(messages)
|
||||
Reference in New Issue
Block a user