Compare commits

..

52 Commits

Author SHA1 Message Date
openhands
c088a08e51 style: Fix linting in test_listen.py 2024-11-16 14:24:11 +00:00
openhands
2ce806e411 fix: Convert ResolverOutput to dict in response 2024-11-16 14:19:59 +00:00
openhands
45a1486f24 style: Format imports in listen.py 2024-11-16 13:54:26 +00:00
openhands
1f53c930fe refactor: Remove unused SendPullRequestDataModel 2024-11-16 13:51:38 +00:00
openhands
dbf560d21b refactor: Improve resolver API endpoints
1. Delete send_pull_request endpoint in listen.py
2. Remove file writing dependency in resolve_issue endpoint
3. Rename process_single_issue to create_pull_request_from_resolver_output
4. Update all references and fix tests
2024-11-16 13:49:22 +00:00
openhands
845f1b25ea style: Fix linting issues 2024-11-16 13:14:43 +00:00
openhands
031e20105e feat: Combine resolve_issue and send_pull_request API calls into a single endpoint 2024-11-16 13:11:55 +00:00
Graham Neubig
f03748226a Merge branch 'main' into add-resolver-api-endpoints 2024-11-16 08:02:18 -05:00
openhands
27592c504a fix: Fix send-pr endpoint to use correct file path and update tests to use Pydantic models 2024-11-16 13:01:24 +00:00
Ryan H. Tran
97f3249205 Move linter and diff utils to openhands-aci (#5020) 2024-11-16 06:58:26 +01:00
sp.wack
9d47ddba38 Reduce output from frontend tests (#5023) 2024-11-16 06:57:41 +01:00
OpenHands
f7652bd558 Fix issue #5080: [Bug]: lint-fix.yml github action doesn't work on a branch not from this repo (#5081) 2024-11-16 06:55:41 +01:00
openhands
a4f577222a Fix pr #5058: Add API endpoints for resolver functionality 2024-11-16 04:08:08 +00:00
openhands
c2265e83c5 Fix pr #5058: Add API endpoints for resolver functionality 2024-11-16 03:02:33 +00:00
Graham Neubig
87925dd876 Merge branch 'main' into add-resolver-api-endpoints 2024-11-15 21:54:20 -05:00
OpenHands
2b7932b46c Fix issue #5070: [Bug]: lint-fix workflow is failing (#5078) 2024-11-16 01:43:49 +00:00
Graham Neubig
95884c1c74 Lint 2024-11-15 20:01:16 -05:00
OpenHands
7074e45ec3 Fix issue #5059: [Bug]: Github resolver looking for wrong PR number (#5062)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-15 19:41:48 -05:00
Graham Neubig
cfd3911f2b Refactor resolver endpoints to use data models (#5073)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-15 18:11:01 -05:00
Graham Neubig
abde56ff7e Update 2024-11-15 17:28:21 -05:00
openhands
66b4e5d14b Fix failing tests in test_listen.py 2024-11-15 21:56:11 +00:00
Graham Neubig
cb92518f1b Merge branch 'main' into add-resolver-api-endpoints 2024-11-15 16:18:28 -05:00
openhands
486355bfd5 Improve process_single_issue return type and error handling 2024-11-15 21:18:08 +00:00
Raymond Xu
a679fcc3b5 [docs] add tips from Graham Neubig on how to make good contributions (#5012)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-15 21:15:11 +00:00
Raymond Xu
8b1d5f5a3b Always push repo or make a PR, comment (#5063) 2024-11-15 21:14:47 +00:00
mamoodi
9882b62777 Update some OpenHands repo documentation and the official document site (#5060) 2024-11-15 20:48:02 +00:00
OpenHands
b49bdb9d85 Fix issue #5064: lint-fix github action (#5065) 2024-11-15 15:47:24 -05:00
openhands
fba35a4be8 Revert changes to send_pull_request.py
Keep the original implementation to avoid modifying core functionality
2024-11-15 18:32:58 +00:00
openhands
73e190e0f1 Add API endpoints for resolver functionality
- Add /api/resolver/resolve-issue endpoint to resolve GitHub issues
- Add /api/resolver/send-pr endpoint to create PRs/branches
- Add tests for new endpoints
- Make resolver functions async for better API integration
2024-11-15 18:29:22 +00:00
mamoodi
00ffc33d1b Release 0.14.0 (#5027) 2024-11-15 16:02:02 +00:00
sp.wack
1acb66c2b3 feat(frontend): Create push to Github action button in chat interface (#4993) 2024-11-15 15:12:13 +00:00
Xingyao Wang
5b3db1bd33 feat: make add_in_context_learning_example configurable in fn call converter (#5018) 2024-11-15 23:05:05 +08:00
Xingyao Wang
bdc4513937 fix(swebench): handle error in eval_infer and run_infer (#5017) 2024-11-15 23:04:56 +08:00
sp.wack
ffc4d32440 feat(frontend): Keep prompt after project upload or repo selection (#4925) 2024-11-15 16:56:47 +02:00
sp.wack
9cd248d475 feat(frontend): Display runtime ID in the browser console if available (#4978) 2024-11-15 16:38:31 +02:00
OpenHands
5f52eebb40 Fix issue #5021: Add links to the resolver messages (#5022) 2024-11-15 13:05:25 +00:00
Graham Neubig
b0c4580999 Update openhands-resolver.yml with correct package name (#5014) 2024-11-15 06:48:18 -05:00
Robert Brennan
f3b35663e9 fix zip downloads (#5009) 2024-11-14 17:17:36 -05:00
OpenHands
be92965209 Fix issue #4944: [Bug]: Missing GitHub token link in account settings (#4946)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-14 22:21:02 +02:00
sp.wack
89b304ccb7 refactor(frontend): Improve chat input padding (#4928) 2024-11-14 22:19:04 +02:00
sp.wack
01cacf7c33 feat(frontend): Wait for events before rendering messages (#4994)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2024-11-14 22:09:29 +02:00
Engel Nyst
fac5237c69 Fix user commands in terminal with function calling (#4955)
Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2024-11-14 19:14:36 +00:00
Robert Brennan
c784151765 fix file descriptor leaks (#4988)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-14 14:06:33 -05:00
Graham Neubig
ce6f99d80e Add GITHUB_USERNAME env var to resolver step (#4999)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-14 18:42:59 +00:00
Ketan Ramaneti
852c90f64a [fix eval] Fix issues with miniwob remote runtime evaluation (#5001) 2024-11-14 18:00:48 +00:00
Ketan Ramaneti
42b49e6c43 [fix eval] Fix issues with aider_bench remote runtime evaluation (#5000) 2024-11-14 17:58:45 +00:00
Xingyao Wang
07f0d1ccb3 feat(llm): convert function call request for non-funcall OSS model (#4711)
Co-authored-by: Calvin Smith <email@cjsmith.io>
2024-11-15 00:40:09 +08:00
Robert Brennan
52a428d74a Fix markdown ordered list numbering (#4989)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-14 10:59:48 -05:00
OpenHands
27cd507cd2 Fix issue #4985: [Bug]: Cannot exit the session when on Jupyter or Browser tab in the UI (#4986) 2024-11-14 10:06:35 -05:00
Graham Neubig
a753babb7a Integrate OpenHands resolver into main repository (#4964)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2024-11-14 09:45:46 -05:00
Rohit Malhotra
38dc41ca42 Fix: [Bug] Do not render editor action buttons (save/discard) when displaying non-code files (#4903) 2024-11-14 09:09:28 +02:00
Engel Nyst
8dee334236 Context Window Exceeded fix (#4977) 2024-11-14 02:42:39 +00:00
157 changed files with 11450 additions and 2762 deletions

61
.github/workflows/lint-fix.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
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 --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

View File

@@ -1,15 +1,269 @@
name: Resolve Issues with OpenHands
name: Auto-Fix Tagged Issue with OpenHands
on:
workflow_call:
inputs:
max_iterations:
required: false
type: number
default: 50
macro:
required: false
type: string
default: "@openhands-agent"
secrets:
LLM_MODEL:
required: true
LLM_API_KEY:
required: true
LLM_BASE_URL:
required: false
PAT_TOKEN:
required: true
PAT_USERNAME:
required: true
issues:
types: [labeled]
pull_request:
types: [labeled]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
call-openhands-resolver:
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
if: github.event.label.name == 'fix-me'
with:
max_iterations: 50
secrets: inherit
auto-fix:
if: |
github.event_name == 'workflow_call' ||
github.event.label.name == 'fix-me' ||
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') &&
(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') &&
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Get latest versions and create requirements.txt
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
echo "openhands-ai==${OPENHANDS_VERSION}" >> requirements.txt
cat requirements.txt
- name: Cache pip dependencies
if: github.event.label.name != 'fix-me-experimental'
uses: actions/cache@v3
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('requirements.txt') }}
- name: Check required environment variables
env:
LLM_MODEL: ${{ secrets.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
run: |
required_vars=("LLM_MODEL" "LLM_API_KEY" "PAT_TOKEN" "PAT_USERNAME")
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "Error: Required environment variable $var is not set."
exit 1
fi
done
- name: Set environment variables
run: |
if [ -n "${{ github.event.review.body }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
elif [ -n "${{ github.event.issue.pull_request }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
elif [ -n "${{ github.event.pull_request.number }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
else
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
fi
if [ -n "${{ github.event.review.body }}" ]; then
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
else
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
fi
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
- name: Comment on issue with start message
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issueType = process.env.ISSUE_TYPE;
github.rest.issues.createComment({
issue_number: ${{ env.ISSUE_NUMBER }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `[OpenHands](https://github.com/All-Hands-AI/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
});
- name: Install OpenHands
run: |
if [ "${{ github.event.label.name }}" == "fix-me-experimental" ]; then
python -m pip install --upgrade pip
pip install git+https://github.com/all-hands-ai/openhands.git
else
python -m pip install --upgrade -r requirements.txt
fi
- name: Attempt to resolve issue
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
PYTHONPATH: ""
run: |
cd /tmp && python -m openhands.resolver.resolve_issue \
--repo ${{ github.repository }} \
--issue-number ${{ env.ISSUE_NUMBER }} \
--issue-type ${{ env.ISSUE_TYPE }} \
--max-iterations ${{ env.MAX_ITERATIONS }} \
--comment-id ${{ env.COMMENT_ID }}
- name: Check resolution result
id: check_result
run: |
if cd /tmp && grep -q '"success":true' output/output.jsonl; then
echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
else
echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v4
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
path: /tmp/output/output.jsonl
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 }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
PYTHONPATH: ""
run: |
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--pr-type draft | tee pr_result.txt && \
grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
else
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--pr-type branch \
--send-on-failure | tee branch_result.txt && \
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
fi
- 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: |
const fs = require('fs');
const issueNumber = ${{ env.ISSUE_NUMBER }};
const success = ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }};
let prNumber = '';
let branchName = '';
let logContent = '';
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
try {
if (success){
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
} else {
logContent = fs.readFileSync('/tmp/branch_result.txt', 'utf8').trim();
}
} catch (error) {
console.error('Error reading results file:', error);
}
try {
if (success) {
prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
} else {
branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
}
} catch (error) {
console.error('Error reading file:', error);
}
if (logContent.includes(noChangesMessage)) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
});
} else if (success && prNumber) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
});
} else if (!success && branchName) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`
});
} else {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
});
}

3
.gitignore vendored
View File

@@ -176,6 +176,9 @@ evaluation/gorilla/data
evaluation/toolqa/data
evaluation/scienceagentbench/benchmark
# openhands resolver
output/
# frontend
# dependencies

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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:

View File

@@ -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.**
[![Star History Chart](https://api.star-history.com/svg?repos=All-Hands-AI/OpenHands&type=Date)](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,9 @@ 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
## Licensing, Contributing, Community Servers
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).
For guides on how to contribute to OpenHands, joining our Discord and Slack servers
[check out the OpenHands README.](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-how-to-contribute)

View File

@@ -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
```

View File

@@ -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!

View File

@@ -12,4 +12,5 @@ To use the OpenHands GitHub Action in the OpenHands repository, an OpenHands mai
## 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).

View File

@@ -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"
```

View File

@@ -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).

View File

@@ -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

View File

@@ -87,9 +87,7 @@ class Q20Game:
# others
bingo, anwser_reply = self.judge_winner(response)
if bingo:
return (
'You are bingo! quit now, run: <execute_bash> exit </execute_bash>.\n'
)
return 'You are bingo! Use the "finish" tool to finish the interaction.\n'
if self.curr_turn == self.num_turns - 2:
anwser_reply += " You must guess now, what's it?"
return anwser_reply

View File

@@ -56,6 +56,20 @@ You can update the arguments in the script
./evaluation/aider_bench/scripts/run_infer.sh eval_gpt35_turbo HEAD CodeActAgent 100 1 "1,3,10"
```
### Run Inference on `RemoteRuntime` (experimental)
This is in limited beta. Contact Xingyao over slack if you want to try this out!
```bash
./evaluation/aider_bench/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [eval-num-workers] [eval_ids]
# Example - This runs evaluation on CodeActAgent for 133 instances on aider_bench test set, with 2 workers running in parallel
export ALLHANDS_API_KEY="YOUR-API-KEY"
export RUNTIME=remote
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
./evaluation/aider_bench/scripts/run_infer.sh llm.eval HEAD CodeActAgent 133 2
```
## Summarize Results
```bash

View File

@@ -58,6 +58,9 @@ def get_config(
use_host_network=False,
timeout=100,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=1800,
),
# do not mount workspace
workspace_base=None,

View File

@@ -40,7 +40,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n'
}
FILE_EXT_MAP = {

File diff suppressed because one or more lines are too long

View File

@@ -40,7 +40,7 @@ from openhands.utils.async_utils import call_async_from_sync
def codeact_user_response(state: State) -> str:
msg = (
'Please continue working on the task on whatever approach you think is suitable.\n'
'If you think you have completed the SQL, please run the following command: <execute_bash> exit </execute_bash>.\n'
'If you think you have completed the SQL, please finish the interaction using the "finish" tool.\n'
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN HELP OR USE THE INTERNET TO SOLVE THIS TASK.\n'
)
if state.history:
@@ -54,7 +54,7 @@ def codeact_user_response(state: State) -> str:
# let the agent know that it can give up when it has tried 3 times
return (
msg
+ 'If you want to give up, run: <execute_bash> exit </execute_bash>.\n'
+ 'If you want to give up, use the "finish" tool to finish the interaction.\n'
)
return msg
@@ -64,7 +64,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n'
}

View File

@@ -55,7 +55,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n'
}

View File

@@ -33,7 +33,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have completed the request, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have completed the request, please finish the interaction using the "finish" tool.\n'
}

View File

@@ -87,11 +87,10 @@ def gpqa_codeact_user_response(
msg = (
'Please continue working on the task on whatever approach you think is suitable.\n'
'Feel free to use all tools for calculations and solving the problem, and web-search for finding relevant facts during the process if needed\n'
'If you have finished reporting the answer in the expected format, (and only once that is done), please run the following command to submit: <execute_bash> exit </execute_bash>.\n'
'If you have finished reporting the answer in the expected format, (and only once that is done), please use the "finish" tool to finish the interaction.\n'
'Again you are being told a million times to first report the answer in the requested format (see again below for reference) before exiting. DO NOT EXIT WITHOUT REPORTING THE ANSWER FIRST.\n'
'That is, when you have decided on the answer report in the following format:\n'
f'{ACTION_FORMAT}\n'
'<execute_bash> exit </execute_bash>\n'
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN HELP TO SOLVE THIS TASK.\n'
)
return msg
@@ -100,7 +99,7 @@ def gpqa_codeact_user_response(
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {'CodeActAgent': gpqa_codeact_user_response}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': '\n\n SUPER IMPORTANT: When you think you have solved the question, first report it back to the user in the requested format. Only once that is done, in the next turn, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': '\n\n SUPER IMPORTANT: When you think you have solved the question, first report it back to the user in the requested format. Only once that is done, in the next turn, please finish the interaction using the "finish" tool.\n'
}
@@ -205,12 +204,11 @@ Additional Instructions:
- Do not try to solve the question in a single step. Break it down into smaller steps.
- You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.
- SUPER IMPORTANT: When you have reported the answer to the user in the requested format, (and only once that is done) in the next turn, please run the following command: <execute_bash> exit </execute_bash>.
- SUPER IMPORTANT: When you have reported the answer to the user in the requested format, (and only once that is done) in the next turn, please finish the interaction using the "finish" tool.
- Again you are being told a million times to first report the answer in the requested format (see again below for reference) before exiting. DO NOT EXIT WITHOUT REPORTING THE ANSWER FIRST.
That is, when you have decided on the answer report in the following format:
{ACTION_FORMAT}
<execute_bash> exit </execute_bash>
Again do not quit without reporting the answer first.
Ok now its time to start solving the question. Good luck!

View File

@@ -23,7 +23,7 @@ For each problem, OpenHands is given a set number of iterations to fix the faili
```
{
"task_id": "Python/2",
"instruction": "Please fix the function in Python__2.py such that all test cases pass.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\ndef truncate_number(number: float) -> float:\n return number % 1.0 + 1.0\n\n\n\n\n\n\ndef check(truncate_number):\n assert truncate_number(3.5) == 0.5\n assert abs(truncate_number(1.33) - 0.33) < 1e-6\n assert abs(truncate_number(123.456) - 0.456) < 1e-6\n\ncheck(truncate_number)\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n",
"instruction": "Please fix the function in Python__2.py such that all test cases pass.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\ndef truncate_number(number: float) -> float:\n return number % 1.0 + 1.0\n\n\n\n\n\n\ndef check(truncate_number):\n assert truncate_number(3.5) == 0.5\n assert abs(truncate_number(1.33) - 0.33) < 1e-6\n assert abs(truncate_number(123.456) - 0.456) < 1e-6\n\ncheck(truncate_number)\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n",
"metadata": {
"agent_class": "CodeActAgent",
"model_name": "gpt-4",
@@ -38,10 +38,10 @@ For each problem, OpenHands is given a set number of iterations to fix the faili
"id": 27,
"timestamp": "2024-05-22T20:57:24.688651",
"source": "user",
"message": "Please fix the function in Python__2.py such that all test cases pass.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\ndef truncate_number(number: float) -> float:\n return number % 1.0 + 1.0\n\n\n\n\n\n\ndef check(truncate_number):\n assert truncate_number(3.5) == 0.5\n assert abs(truncate_number(1.33) - 0.33) < 1e-6\n assert abs(truncate_number(123.456) - 0.456) < 1e-6\n\ncheck(truncate_number)\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n",
"message": "Please fix the function in Python__2.py such that all test cases pass.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\ndef truncate_number(number: float) -> float:\n return number % 1.0 + 1.0\n\n\n\n\n\n\ndef check(truncate_number):\n assert truncate_number(3.5) == 0.5\n assert abs(truncate_number(1.33) - 0.33) < 1e-6\n assert abs(truncate_number(123.456) - 0.456) < 1e-6\n\ncheck(truncate_number)\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n",
"action": "message",
"args": {
"content": "Please fix the function in Python__2.py such that all test cases pass.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\ndef truncate_number(number: float) -> float:\n return number % 1.0 + 1.0\n\n\n\n\n\n\ndef check(truncate_number):\n assert truncate_number(3.5) == 0.5\n assert abs(truncate_number(1.33) - 0.33) < 1e-6\n assert abs(truncate_number(123.456) - 0.456) < 1e-6\n\ncheck(truncate_number)\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n",
"content": "Please fix the function in Python__2.py such that all test cases pass.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\ndef truncate_number(number: float) -> float:\n return number % 1.0 + 1.0\n\n\n\n\n\n\ndef check(truncate_number):\n assert truncate_number(3.5) == 0.5\n assert abs(truncate_number(1.33) - 0.33) < 1e-6\n assert abs(truncate_number(123.456) - 0.456) < 1e-6\n\ncheck(truncate_number)\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n",
"wait_for_response": false
}
},

View File

@@ -75,7 +75,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have fixed the issue through code changes, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have fixed the issue through code changes, please finish the interaction using the "finish" tool.\n'
}

View File

@@ -16,6 +16,20 @@ Access with browser the above MiniWoB URLs and see if they load correctly.
./evaluation/miniwob/scripts/run_infer.sh llm.claude-35-sonnet-eval
```
### Run Inference on `RemoteRuntime` (experimental)
This is in limited beta. Contact Xingyao over slack if you want to try this out!
```bash
./evaluation/miniwob/scripts/run_infer.sh [model_config] [git-version] [agent] [note] [eval_limit] [num_workers]
# Example - This runs evaluation on BrowsingAgent for 125 instances on miniwob, with 2 workers running in parallel
export ALLHANDS_API_KEY="YOUR-API-KEY"
export RUNTIME=remote
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
./evaluation/miniwob/scripts/run_infer.sh llm.eval HEAD BrowsingAgent "" 125 2
```
Results will be in `evaluation/evaluation_outputs/outputs/miniwob/`
To calculate the average reward, run:

View File

@@ -23,7 +23,7 @@ if __name__ == '__main__':
data = json.loads(line)
actual_num += 1
total_cost += data['metrics']['accumulated_cost']
total_reward += data['test_result']
total_reward += data['test_result']['reward']
avg_reward = total_reward / total_num
print('Avg Reward: ', avg_reward)

View File

@@ -47,6 +47,7 @@ SUPPORTED_AGENT_CLS = {'BrowsingAgent', 'CodeActAgent'}
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
'CodeActAgent': codeact_user_response,
'BrowsingAgent': 'Continue the task. IMPORTANT: do not talk to the user until you have finished the task',
}
@@ -66,7 +67,9 @@ def get_config(
browsergym_eval_env=env_id,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
keep_runtime_alive=False,
timeout=120,
),
# do not mount workspace
workspace_base=None,

View File

@@ -33,7 +33,7 @@ echo "MODEL_CONFIG: $MODEL_CONFIG"
EVAL_NOTE="${AGENT_VERSION}_${NOTE}"
COMMAND="poetry run python evaluation/miniwob/run_infer.py \
COMMAND="export PYTHONPATH=evaluation/miniwob:\$PYTHONPATH && poetry run python evaluation/miniwob/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations 10 \

View File

@@ -70,7 +70,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': '\nIMPORTANT: When your answer is confirmed by the user to be correct, you can exit using the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'IMPORTANT: When your answer is confirmed by the user to be correct, you can use the "finish" tool to finish the interaction.\n'
}
with open(os.path.join(os.path.dirname(__file__), 'requirements.txt'), 'r') as f:

View File

@@ -55,7 +55,7 @@ Here's an example of the evaluation output for a single task instance:
{
"instance_id": 3,
"repo": "https://github.com/dmlc/dgl",
"instruction": "Please complete the Machine Learning task in the following repository: dgl\n\nThe task is: DGL Implementation of NGCF model\n\nI have a deep desire to embark on a journey brimming with knowledge and expertise. My objective is to train a cutting-edge NGCF Model, known for its unparalleled capabilities, on the illustrious dataset known as gowalla. To ensure swift execution, I kindly request your assistance in crafting the code, making use of the powerful GPU #3 and an embedding size of 32. Can you lend a helping hand to transform this dream into a reality?\n\nYou should create a script named `run.sh` under the specified path in the repo to run the task.\n\nYou can find the task repo at: /workspace/dgl/examples/pytorch/NGCF/NGCF\n\nYou should terminate the subprocess after running the task (e.g., call subprocess.Popen(args).wait()).When you think you have completed the task, please run the following command: <execute_bash> exit </execute_bash>.\n",
"instruction": "Please complete the Machine Learning task in the following repository: dgl\n\nThe task is: DGL Implementation of NGCF model\n\nI have a deep desire to embark on a journey brimming with knowledge and expertise. My objective is to train a cutting-edge NGCF Model, known for its unparalleled capabilities, on the illustrious dataset known as gowalla. To ensure swift execution, I kindly request your assistance in crafting the code, making use of the powerful GPU #3 and an embedding size of 32. Can you lend a helping hand to transform this dream into a reality?\n\nYou should create a script named `run.sh` under the specified path in the repo to run the task.\n\nYou can find the task repo at: /workspace/dgl/examples/pytorch/NGCF/NGCF\n\nYou should terminate the subprocess after running the task (e.g., call subprocess.Popen(args).wait()).When you think you have completed the task, please finish the interaction using the "finish" tool.\n",
"metadata": {
"agent_class": "CodeActAgent",
"model_name": "gpt-4-1106-preview",
@@ -70,10 +70,10 @@ Here's an example of the evaluation output for a single task instance:
"id": 0,
"timestamp": "2024-05-26T17:40:41.060009",
"source": "user",
"message": "Please complete the Machine Learning task in the following repository: dgl\n\nThe task is: DGL Implementation of NGCF model\n\nI have a deep desire to embark on a journey brimming with knowledge and expertise. My objective is to train a cutting-edge NGCF Model, known for its unparalleled capabilities, on the illustrious dataset known as gowalla. To ensure swift execution, I kindly request your assistance in crafting the code, making use of the powerful GPU #3 and an embedding size of 32. Can you lend a helping hand to transform this dream into a reality?\n\nYou should create a script named `run.sh` under the specified path in the repo to run the task.\n\nYou can find the task repo at: /workspace/dgl/examples/pytorch/NGCF/NGCF\n\nYou should terminate the subprocess after running the task (e.g., call subprocess.Popen(args).wait()).When you think you have completed the task, please run the following command: <execute_bash> exit </execute_bash>.\n",
"message": "Please complete the Machine Learning task in the following repository: dgl\n\nThe task is: DGL Implementation of NGCF model\n\nI have a deep desire to embark on a journey brimming with knowledge and expertise. My objective is to train a cutting-edge NGCF Model, known for its unparalleled capabilities, on the illustrious dataset known as gowalla. To ensure swift execution, I kindly request your assistance in crafting the code, making use of the powerful GPU #3 and an embedding size of 32. Can you lend a helping hand to transform this dream into a reality?\n\nYou should create a script named `run.sh` under the specified path in the repo to run the task.\n\nYou can find the task repo at: /workspace/dgl/examples/pytorch/NGCF/NGCF\n\nYou should terminate the subprocess after running the task (e.g., call subprocess.Popen(args).wait()).When you think you have completed the task, please finish the interaction using the "finish" tool.\n",
"action": "message",
"args": {
"content": "Please complete the Machine Learning task in the following repository: dgl\n\nThe task is: DGL Implementation of NGCF model\n\nI have a deep desire to embark on a journey brimming with knowledge and expertise. My objective is to train a cutting-edge NGCF Model, known for its unparalleled capabilities, on the illustrious dataset known as gowalla. To ensure swift execution, I kindly request your assistance in crafting the code, making use of the powerful GPU #3 and an embedding size of 32. Can you lend a helping hand to transform this dream into a reality?\n\nYou should create a script named `run.sh` under the specified path in the repo to run the task.\n\nYou can find the task repo at: /workspace/dgl/examples/pytorch/NGCF/NGCF\n\nYou should terminate the subprocess after running the task (e.g., call subprocess.Popen(args).wait()).When you think you have completed the task, please run the following command: <execute_bash> exit </execute_bash>.\n",
"content": "Please complete the Machine Learning task in the following repository: dgl\n\nThe task is: DGL Implementation of NGCF model\n\nI have a deep desire to embark on a journey brimming with knowledge and expertise. My objective is to train a cutting-edge NGCF Model, known for its unparalleled capabilities, on the illustrious dataset known as gowalla. To ensure swift execution, I kindly request your assistance in crafting the code, making use of the powerful GPU #3 and an embedding size of 32. Can you lend a helping hand to transform this dream into a reality?\n\nYou should create a script named `run.sh` under the specified path in the repo to run the task.\n\nYou can find the task repo at: /workspace/dgl/examples/pytorch/NGCF/NGCF\n\nYou should terminate the subprocess after running the task (e.g., call subprocess.Popen(args).wait()).When you think you have completed the task, please finish the interaction using the "finish" tool.\n",
"wait_for_response": false
}
},

View File

@@ -52,7 +52,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have completed the task, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have completed the task, please finish the interaction using the "finish" tool.\n'
}
ID2CONDA = {

View File

@@ -84,7 +84,7 @@ def get_config(instance: pd.Series) -> AppConfig:
timeout=1800,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
remote_runtime_init_timeout=3600,
),
# do not mount workspace
workspace_base=None,
@@ -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)

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
CODEACT_SWE_PROMPT = """Now, you're going to solve this issue on your own. Your terminal session has started and you're in the repository's root directory. You can use any bash commands or the special interface to help you. Edit all the files you need to and run any checks or tests that you want.
Remember, YOU CAN ONLY ENTER ONE COMMAND AT A TIME. You should always wait for feedback after every command.
When you're satisfied with all of the changes you've made, you can run the following command: <execute_bash> exit </execute_bash>.
When you're satisfied with all of the changes you've made, you can use the "finish" tool to finish the interaction.
Note however that you cannot use any interactive session commands (e.g. vim) in this environment, but you can write scripts and run them. E.g. you can write a python script and then run it with `python <script_name>.py`.
NOTE ABOUT THE EDIT COMMAND: Indentation really matters! When editing a file, make sure to insert appropriate indentation before each line!

View File

@@ -145,8 +145,8 @@ 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_runtime_alive=False,
remote_runtime_init_timeout=1800,
keep_remote_runtime_alive=False,
remote_runtime_init_timeout=3600,
),
# do not mount workspace
workspace_base=None,
@@ -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
)

View File

@@ -34,7 +34,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
AGENT_CLS_TO_INST_SUFFIX = {
'CodeActAgent': 'When you think you have completed the request, please run the following command: <execute_bash> exit </execute_bash>.\n'
'CodeActAgent': 'When you think you have completed the request, please finish the interaction using the "finish" tool.\n'
}

View File

@@ -137,7 +137,7 @@ def codeact_user_response(
# let the agent know that it can give up when it has tried 3 times
return (
msg
+ 'If you want to give up, run: <execute_bash> exit </execute_bash>.\n'
+ 'If you want to give up, use the "finish" tool to finish the interaction.\n'
)
return msg

View File

@@ -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,

View File

@@ -0,0 +1,93 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRate } from "#/utils/use-rate";
describe("useRate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should initialize", () => {
const { result } = renderHook(() => useRate());
expect(result.current.items).toHaveLength(0);
expect(result.current.rate).toBeNull();
expect(result.current.lastUpdated).toBeNull();
expect(result.current.isUnderThreshold).toBe(true);
});
it("should handle the case of a single element", () => {
const { result } = renderHook(() => useRate());
act(() => {
result.current.record(123);
});
expect(result.current.items).toHaveLength(1);
expect(result.current.lastUpdated).not.toBeNull();
});
it("should return the difference between the last two elements", () => {
const { result } = renderHook(() => useRate());
vi.setSystemTime(500);
act(() => {
result.current.record(4);
});
vi.advanceTimersByTime(500);
act(() => {
result.current.record(9);
});
expect(result.current.items).toHaveLength(2);
expect(result.current.rate).toBe(5);
expect(result.current.lastUpdated).toBe(1000);
});
it("should update isUnderThreshold after [threshold]ms of no activity", () => {
const { result } = renderHook(() => useRate({ threshold: 500 }));
expect(result.current.isUnderThreshold).toBe(true);
act(() => {
// not sure if fake timers is buggy with intervals,
// but I need to call it twice to register
vi.advanceTimersToNextTimer();
vi.advanceTimersToNextTimer();
});
expect(result.current.isUnderThreshold).toBe(false);
});
it("should return an isUnderThreshold boolean", () => {
const { result } = renderHook(() => useRate({ threshold: 500 }));
vi.setSystemTime(500);
act(() => {
result.current.record(400);
});
act(() => {
result.current.record(1000);
});
expect(result.current.isUnderThreshold).toBe(false);
act(() => {
result.current.record(1500);
});
expect(result.current.isUnderThreshold).toBe(true);
act(() => {
vi.advanceTimersToNextTimer();
vi.advanceTimersToNextTimer();
});
expect(result.current.isUnderThreshold).toBe(false);
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.13.1",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.13.1",
"version": "0.14.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"engines": {
@@ -120,4 +120,4 @@
"public"
]
}
}
}

View File

@@ -2,4 +2,4 @@
"APP_MODE": "oss",
"GITHUB_CLIENT_ID": "",
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
}
}

View File

@@ -183,6 +183,13 @@ class OpenHands {
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
return request(`/api/vscode-url`, {}, false, false, 1);
}
static async getRuntimeId(): Promise<{ runtime_id: string }> {
const response = await request("/api/config");
const data = await response.json();
return data;
}
}
export default OpenHands;

View File

@@ -18,6 +18,7 @@ interface ChatInputProps {
onBlur?: () => void;
onImagePaste?: (files: File[]) => void;
className?: React.HTMLAttributes<HTMLDivElement>["className"];
buttonClassName?: React.HTMLAttributes<HTMLButtonElement>["className"];
}
export function ChatInput({
@@ -35,6 +36,7 @@ export function ChatInput({
onBlur,
onImagePaste,
className,
buttonClassName,
}: ChatInputProps) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
@@ -100,7 +102,7 @@ export function ChatInput({
return (
<div
data-testid="chat-input"
className="flex items-end justify-end grow gap-1 min-h-6"
className="flex items-end justify-end grow gap-1 min-h-6 w-full"
>
<TextareaAutosize
ref={textareaRef}
@@ -128,7 +130,7 @@ export function ChatInput({
)}
/>
{showButton && (
<>
<div className={buttonClassName}>
{button === "submit" && (
<button
aria-label="Send"
@@ -152,7 +154,7 @@ export function ChatInput({
<div className="w-[10px] h-[10px] bg-white" />
</button>
)}
</>
</div>
)}
</div>
);

View File

@@ -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,18 +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 } = 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);
@@ -42,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", {
@@ -72,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 && (
@@ -101,29 +140,64 @@ export function ChatInterface() {
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{messages.map((message, index) =>
isErrorMessage(message) ? (
<ErrorMessage
key={index}
id={message.id}
message={message.message}
/>
) : (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
curAgentState === AgentState.AWAITING_USER_CONFIRMATION && (
<ConfirmationButtons />
{isLoadingMessages && (
<div className="flex justify-center">
<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) ? (
<ErrorMessage
key={index}
id={message.id}
message={message.message}
/>
) : (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
</ChatMessage>
),
{messages.length - 1 === index &&
message.sender === "assistant" &&
curAgentState === AgentState.AWAITING_USER_CONFIRMATION && (
<ConfirmationButtons />
)}
</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>

View File

@@ -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
}
}

View File

@@ -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 && (

View File

@@ -56,7 +56,7 @@ export function InteractiveChatBox({
<div
className={cn(
"flex items-end gap-1",
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
"bg-neutral-700 border border-neutral-600 rounded-lg px-2",
"transition-colors duration-200",
"hover:border-neutral-500 focus-within:border-neutral-500",
)}
@@ -71,6 +71,8 @@ export function InteractiveChatBox({
onStop={onStop}
value={value}
onImagePaste={handleUpload}
className="py-[10px]"
buttonClassName="py-[10px]"
/>
</div>
</div>

View File

@@ -4,8 +4,8 @@ import { ExtraProps } from "react-markdown";
// Custom component to render <ul> in markdown
export function ul({
children,
}: React.ClassAttributes<HTMLElement> &
React.HTMLAttributes<HTMLElement> &
}: React.ClassAttributes<HTMLUListElement> &
React.HTMLAttributes<HTMLUListElement> &
ExtraProps) {
return <ul className="list-disc ml-5 pl-2 whitespace-normal">{children}</ul>;
}
@@ -13,14 +13,12 @@ export function ul({
// Custom component to render <ol> in markdown
export function ol({
children,
}: React.ClassAttributes<HTMLElement> &
React.HTMLAttributes<HTMLElement> &
start,
}: React.ClassAttributes<HTMLOListElement> &
React.OlHTMLAttributes<HTMLOListElement> &
ExtraProps) {
return (
<ol
className="list-decimal ml-5 pl-2 whitespace-normal"
style={{ counterReset: "list-item" }}
>
<ol className="list-decimal ml-5 pl-2 whitespace-normal" start={start}>
{children}
</ol>
);

View File

@@ -1,7 +1,10 @@
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/BaseModal";
import ModalBody from "./ModalBody";
import ModalButton from "../buttons/ModalButton";
import FormFieldset from "../form/FormFieldset";
@@ -87,6 +90,17 @@ function AccountSettingsModal({
type="password"
defaultValue={data?.ghToken ?? ""}
/>
<BaseModalDescription>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
</a>
</BaseModalDescription>
{gitHubError && (
<p className="text-danger text-xs">
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}

View File

@@ -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>

View File

@@ -11,7 +11,7 @@ export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
};
return (
<label className="cursor-pointer">
<label className="cursor-pointer py-[10px]">
{label || <Clip data-testid="default-label" width={24} height={24} />}
<input
data-testid="upload-image-input"

View File

@@ -5,6 +5,10 @@ import ActionType from "#/types/ActionType";
import EventLogger from "#/utils/event-logger";
import AgentState from "#/types/AgentState";
import { handleAssistantMessage } from "#/services/actions";
import { useRate } from "#/utils/use-rate";
const isOpenHandsMessage = (event: Record<string, unknown>) =>
event.action === "message";
const RECONNECT_RETRIES = 5;
@@ -17,12 +21,14 @@ export enum WsClientProviderStatus {
interface UseWsClient {
status: WsClientProviderStatus;
isLoadingMessages: boolean;
events: Record<string, unknown>[];
send: (event: Record<string, unknown>) => void;
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.STOPPED,
isLoadingMessages: true,
events: [],
send: () => {
throw new Error("not connected");
@@ -51,6 +57,8 @@ export function WsClientProvider({
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const [retryCount, setRetryCount] = React.useState(RECONNECT_RETRIES);
const messageRateHandler = useRate({ threshold: 500 });
function send(event: Record<string, unknown>) {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
@@ -71,6 +79,9 @@ export function WsClientProvider({
function handleMessage(messageEvent: MessageEvent) {
const event = JSON.parse(messageEvent.data);
if (isOpenHandsMessage(event)) {
messageRateHandler.record(new Date().getTime());
}
setEvents((prevEvents) => [...prevEvents, event]);
if (event.extras?.agent_state === AgentState.INIT) {
setStatus(WsClientProviderStatus.ACTIVE);
@@ -177,10 +188,11 @@ export function WsClientProvider({
const value = React.useMemo<UseWsClient>(
() => ({
status,
isLoadingMessages: messageRateHandler.isUnderThreshold,
events,
send,
}),
[status, events],
[status, messageRateHandler.isUnderThreshold, events],
);
return (

View File

@@ -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();
}
};

View File

@@ -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
}

View File

@@ -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
@@ -67,7 +66,7 @@ export function TaskForm() {
/>
<div
className={cn(
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
"border border-neutral-600 px-4 rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
"hover:border-neutral-500 focus-within:border-neutral-500",
)}
@@ -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)}
@@ -91,7 +90,8 @@ export function TaskForm() {
value={text}
maxRows={15}
showButton={!!text}
className="text-[17px] leading-5"
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={navigation.state === "submitting"}
/>
</div>
@@ -115,4 +115,6 @@ export function TaskForm() {
)}
</div>
);
}
});
TaskForm.displayName = "TaskForm";

View File

@@ -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);

View File

@@ -8,10 +8,22 @@ 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";
const ASSET_FILE_TYPES = [
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".pdf",
".mp4",
".webm",
".ogg",
];
export const clientLoader = async () => {
const token = localStorage.getItem("token");
return json({ token });
@@ -104,6 +116,10 @@ function CodeEditor() {
if (selectedPath) discardChanges(selectedPath);
};
const isAssetFileType = selectedPath
? ASSET_FILE_TYPES.some((ext) => selectedPath.endsWith(ext))
: false;
return (
<div className="flex h-full bg-neutral-900 relative">
<FileExplorer
@@ -112,7 +128,7 @@ function CodeEditor() {
error={errors.getFiles}
/>
<div className="w-full">
{selectedPath && (
{selectedPath && !isAssetFileType && (
<div className="flex w-full items-center justify-between self-end p-2">
<span className="text-sm text-neutral-500">{selectedPath}</span>
<EditorActions
@@ -122,7 +138,7 @@ function CodeEditor() {
/>
</div>
)}
<CodeEditorCompoonent
<CodeEditorComponent
onMount={handleEditorDidMount}
isReadOnly={!isEditingAllowed}
/>

View File

@@ -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 {

View File

@@ -243,7 +243,7 @@ export default function MainApp() {
type="button"
aria-label="All Hands Logo"
onClick={() => {
if (location.pathname === "/app")
if (location.pathname.startsWith("/app"))
setStartNewProjectModalIsOpen(true);
}}
>

View File

@@ -0,0 +1,67 @@
import React from "react";
interface UseRateProps {
threshold: number;
}
const DEFAULT_CONFIG: UseRateProps = { threshold: 1000 };
export const useRate = (config = DEFAULT_CONFIG) => {
const [items, setItems] = React.useState<number[]>([]);
const [rate, setRate] = React.useState<number | null>(null);
const [lastUpdated, setLastUpdated] = React.useState<number | null>(null);
const [isUnderThreshold, setIsUnderThreshold] = React.useState(true);
/**
* Record an entry in order to calculate the rate
* @param entry Entry to record
*
* @example
* record(new Date().getTime());
*/
const record = (entry: number) => {
setItems((prev) => [...prev, entry]);
setLastUpdated(new Date().getTime());
};
/**
* Update the rate based on the last two entries (if available)
*/
const updateRate = () => {
if (items.length > 1) {
const newRate = items[items.length - 1] - items[items.length - 2];
setRate(newRate);
if (newRate <= config.threshold) setIsUnderThreshold(true);
else setIsUnderThreshold(false);
}
};
React.useEffect(() => {
updateRate();
}, [items]);
React.useEffect(() => {
// Set up an interval to check if the time since the last update exceeds the threshold
// If it does, set isUnderThreshold to false, otherwise set it to true
// This ensures that the component can react to periods of inactivity
const intervalId = setInterval(() => {
if (lastUpdated !== null) {
const timeSinceLastUpdate = new Date().getTime() - lastUpdated;
setIsUnderThreshold(timeSinceLastUpdate <= config.threshold);
} else {
setIsUnderThreshold(false);
}
}, config.threshold);
return () => clearInterval(intervalId);
}, [lastUpdated, config.threshold]);
return {
items,
rate,
lastUpdated,
isUnderThreshold,
record,
};
};

View File

@@ -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);
},
);

View File

@@ -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"],

View File

@@ -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

View File

@@ -1,304 +0,0 @@
import re
from openhands.controller.action_parser import (
ActionParser,
ResponseParser,
)
from openhands.core.exceptions import LLMMalformedActionError
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
AgentDelegateAction,
AgentFinishAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
MessageAction,
)
class CodeActResponseParser(ResponseParser):
"""Parser action:
- CmdRunAction(command) - bash command to run
- FileEditAction(path, content) - edit a file
- IPythonRunCellAction(code) - IPython code to run
- AgentDelegateAction(agent, inputs) - delegate action for (sub)task
- MessageAction(content) - Message action to run (e.g. ask for clarification)
- AgentFinishAction() - end the interaction
"""
def __init__(self):
# Need pay attention to the item order in self.action_parsers
super().__init__()
self.action_parsers = [
CodeActActionParserFinish(),
CodeActActionParserFileEdit(),
CodeActActionParserCmdRun(),
CodeActActionParserIPythonRunCell(),
CodeActActionParserAgentDelegate(),
]
self.default_parser = CodeActActionParserMessage()
def parse(self, response) -> Action:
action_str = self.parse_response(response)
return self.parse_action(action_str)
def parse_response(self, response) -> str:
action = response.choices[0].message.content
if action is None:
return ''
for lang in ['bash', 'ipython', 'browse']:
# special handling for DeepSeek: it has stop-word bug and returns </execute_ipython instead of </execute_ipython>
if f'</execute_{lang}' in action and f'</execute_{lang}>' not in action:
action = action.replace(f'</execute_{lang}', f'</execute_{lang}>')
if f'<execute_{lang}>' in action and f'</execute_{lang}>' not in action:
action += f'</execute_{lang}>'
# special handling for DeepSeek: it has stop-word bug and returns </execute_ipython instead of </execute_ipython>
if '</file_edit' in action and '</file_edit>' not in action:
action = action.replace('</file_edit', '</file_edit>')
if '<file_edit' in action and '</file_edit>' not in action:
action += '</file_edit>'
return action
def parse_action(self, action_str: str) -> Action:
for action_parser in self.action_parsers:
if action_parser.check_condition(action_str):
return action_parser.parse(action_str)
return self.default_parser.parse(action_str)
def action_to_str(self, action: Action) -> str:
if isinstance(action, CmdRunAction):
return (
f'{action.thought}\n<execute_bash>\n{action.command}\n</execute_bash>'
)
elif isinstance(action, IPythonRunCellAction):
return f'{action.thought}\n<execute_ipython>\n{action.code}\n</execute_ipython>'
elif isinstance(action, AgentDelegateAction):
return f'{action.thought}\n<execute_browse>\n{action.inputs["task"]}\n</execute_browse>'
elif isinstance(action, FileEditAction):
return f'{action.thought}\n<file_edit path={action.path}>\n{action.content}\n</file_edit>'
elif isinstance(action, MessageAction):
return action.content
elif isinstance(action, AgentFinishAction) and action.source == 'agent':
return action.thought
return ''
class CodeActActionParserFinish(ActionParser):
"""Parser action:
- AgentFinishAction() - end the interaction
"""
def __init__(
self,
):
self.finish_command = None
def check_condition(self, action_str: str) -> bool:
self.finish_command = re.search(r'<finish>.*</finish>', action_str, re.DOTALL)
return self.finish_command is not None
def parse(self, action_str: str) -> Action:
assert (
self.finish_command is not None
), 'self.finish_command should not be None when parse is called'
thought = action_str.replace(self.finish_command.group(0), '').strip()
return AgentFinishAction(thought=thought)
class CodeActActionParserCmdRun(ActionParser):
"""Parser action:
- CmdRunAction(command) - bash command to run
- AgentFinishAction() - end the interaction
"""
def __init__(
self,
):
self.bash_command = None
def check_condition(self, action_str: str) -> bool:
self.bash_command = re.search(
r'<execute_bash>(.*?)</execute_bash>', action_str, re.DOTALL
)
return self.bash_command is not None
def parse(self, action_str: str) -> Action:
assert (
self.bash_command is not None
), 'self.bash_command should not be None when parse is called'
thought = action_str.replace(self.bash_command.group(0), '').strip()
# a command was found
command_group = self.bash_command.group(1).strip()
if command_group.strip() == 'exit':
return AgentFinishAction(thought=thought)
return CmdRunAction(command=command_group, thought=thought)
class CodeActActionParserIPythonRunCell(ActionParser):
"""Parser action:
- IPythonRunCellAction(code) - IPython code to run
"""
def __init__(
self,
):
self.python_code = None
self.jupyter_kernel_init_code: str = 'from agentskills import *'
def check_condition(self, action_str: str) -> bool:
self.python_code = re.search(
r'<execute_ipython>(.*?)</execute_ipython>', action_str, re.DOTALL
)
return self.python_code is not None
def parse(self, action_str: str) -> Action:
assert (
self.python_code is not None
), 'self.python_code should not be None when parse is called'
code_group = self.python_code.group(1).strip()
thought = action_str.replace(self.python_code.group(0), '').strip()
return IPythonRunCellAction(
code=code_group,
thought=thought,
kernel_init_code=self.jupyter_kernel_init_code,
)
class CodeActActionParserAgentDelegate(ActionParser):
"""Parser action:
- AgentDelegateAction(agent, inputs) - delegate action for (sub)task
"""
def __init__(
self,
):
self.agent_delegate = None
def check_condition(self, action_str: str) -> bool:
self.agent_delegate = re.search(
r'<execute_browse>(.*)</execute_browse>', action_str, re.DOTALL
)
return self.agent_delegate is not None
def parse(self, action_str: str) -> Action:
assert (
self.agent_delegate is not None
), 'self.agent_delegate should not be None when parse is called'
thought = action_str.replace(self.agent_delegate.group(0), '').strip()
browse_actions = self.agent_delegate.group(1).strip()
thought = (
f'{thought}\nI should start with: {browse_actions}'
if thought
else f'I should start with: {browse_actions}'
)
return AgentDelegateAction(
agent='BrowsingAgent', thought=thought, inputs={'task': browse_actions}
)
class CodeActActionParserMessage(ActionParser):
"""Parser action:
- MessageAction(content) - Message action to run (e.g. ask for clarification)
"""
def __init__(
self,
):
pass
def check_condition(self, action_str: str) -> bool:
# We assume the LLM is GOOD enough that when it returns pure natural language
# it wants to talk to the user
return True
def parse(self, action_str: str) -> Action:
return MessageAction(content=action_str, wait_for_response=True)
class CodeActActionParserFileEdit(ActionParser):
"""Parser action:
- FileEditAction(path, content) - edit a file
"""
def __init__(self):
self.file_edit_match: re.Match | None = None
def check_condition(self, action_str: str) -> bool:
if '<file_edit' not in action_str:
return False
# Updated regex to make start and end optional
self.file_edit_match = re.search(
r'<file_edit\s+path=(["\']?)(.*?)\1(?:\s+start=(["\']?)(.*?)\3)?(?:\s+end=(["\']?)(.*?)\5)?\s*>(.*?)</file_edit>',
action_str,
re.DOTALL,
)
if self.file_edit_match is None:
logger.error(
f'FileEditAction detected but the format is incorrect. Unable to match for <file_edit> in:\n{"-" * 80}\n{action_str}\n{"-" * 80}'
)
raise LLMMalformedActionError(
'FileEditAction detected but the format is incorrect. Usage:\n'
'<file_edit path="[path]" start=[start_line] end=[end_line]>\n'
'[content_to_edit]\n'
'</file_edit>\n'
)
path = self.file_edit_match.group(2)
start = self.file_edit_match.group(4)
end = self.file_edit_match.group(6)
if not path:
raise LLMMalformedActionError(
'FileEditAction detected but no `path` specified. You should specify the path of the file to edit.'
)
if start:
try:
int(start)
except ValueError:
raise LLMMalformedActionError(
f'FileEditAction detected but `start` is not a valid integer: {start}'
)
if end:
try:
int(end)
except ValueError:
raise LLMMalformedActionError(
f'FileEditAction detected but `end` is not a valid integer: {end}'
)
return True
def parse(self, action_str: str) -> Action:
assert (
self.file_edit_match is not None
), 'self.file_edit_match should not be None when parse is called'
file_path = self.file_edit_match.group(2).strip()
start_line = (
int(self.file_edit_match.group(4))
if self.file_edit_match.group(4)
else None
)
end_line = (
int(self.file_edit_match.group(6))
if self.file_edit_match.group(6)
else None
)
content = self.file_edit_match.group(7)
thought = action_str.replace(self.file_edit_match.group(0), '').strip()
action = FileEditAction(path=file_path, content=content, thought=thought)
if start_line is not None:
action.start = start_line
if end_line is not None:
action.end = end_line
return action

View File

@@ -1,12 +1,10 @@
import json
import os
from collections import deque
from itertools import islice
from litellm import ModelResponse
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
from openhands.agenthub.codeact_agent.action_parser import CodeActResponseParser
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
@@ -70,7 +68,6 @@ class CodeActAgent(Agent):
AgentSkillsRequirement(),
JupyterRequirement(),
]
obs_prefix = 'OBSERVATION:\n'
def __init__(
self,
@@ -85,36 +82,30 @@ class CodeActAgent(Agent):
super().__init__(llm, config)
self.reset()
self.function_calling_active = self.config.function_calling
if self.function_calling_active and not self.llm.is_function_calling_active():
logger.warning(
f'Function calling not supported for model {self.llm.config.model}. '
'Disabling function calling.'
self.mock_function_calling = False
if not self.llm.is_function_calling_active():
logger.info(
f'Function calling not enabled for model {self.llm.config.model}. '
'Mocking function calling via prompting.'
)
self.function_calling_active = False
self.mock_function_calling = True
if self.function_calling_active:
self.tools = codeact_function_calling.get_tools(
codeact_enable_browsing=self.config.codeact_enable_browsing,
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
codeact_enable_llm_editor=self.config.codeact_enable_llm_editor,
)
logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
disabled_microagents=self.config.disabled_microagents,
)
else:
self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
agent_skills_docs=AgentSkillsRequirement.documentation,
disabled_microagents=self.config.disabled_microagents,
)
# Function calling mode
self.tools = codeact_function_calling.get_tools(
codeact_enable_browsing=self.config.codeact_enable_browsing,
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
codeact_enable_llm_editor=self.config.codeact_enable_llm_editor,
)
logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro')
if self.config.use_microagents
else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
disabled_microagents=self.config.disabled_microagents,
)
self.pending_actions: deque[Action] = deque()
@@ -157,44 +148,33 @@ class CodeActAgent(Agent):
action,
(
AgentDelegateAction,
CmdRunAction,
IPythonRunCellAction,
FileEditAction,
BrowseInteractiveAction,
),
) or (isinstance(action, AgentFinishAction) and action.source == 'agent'):
if self.function_calling_active:
tool_metadata = action.tool_call_metadata
assert tool_metadata is not None, (
'Tool call metadata should NOT be None when function calling is enabled. Action: '
+ str(action)
)
) or (
isinstance(action, (AgentFinishAction, CmdRunAction))
and action.source == 'agent'
):
tool_metadata = action.tool_call_metadata
assert tool_metadata is not None, (
'Tool call metadata should NOT be None when function calling is enabled. Action: '
+ str(action)
)
llm_response: ModelResponse = tool_metadata.model_response
assistant_msg = llm_response.choices[0].message
# Add the LLM message (assistant) that initiated the tool calls
# (overwrites any previous message with the same response_id)
pending_tool_call_action_messages[llm_response.id] = Message(
role=assistant_msg.role,
# tool call content SHOULD BE a string
content=[TextContent(text=assistant_msg.content or '')]
if assistant_msg.content is not None
else [],
tool_calls=assistant_msg.tool_calls,
)
return []
else:
assert not isinstance(action, BrowseInteractiveAction), (
'BrowseInteractiveAction is not supported in non-function calling mode. Action: '
+ str(action)
)
content = [TextContent(text=self.action_parser.action_to_str(action))]
return [
Message(
role='user' if action.source == 'user' else 'assistant',
content=content,
)
]
llm_response: ModelResponse = tool_metadata.model_response
assistant_msg = llm_response.choices[0].message
# Add the LLM message (assistant) that initiated the tool calls
# (overwrites any previous message with the same response_id)
pending_tool_call_action_messages[llm_response.id] = Message(
role=assistant_msg.role,
# tool call content SHOULD BE a string
content=[TextContent(text=assistant_msg.content or '')]
if assistant_msg.content is not None
else [],
tool_calls=assistant_msg.tool_calls,
)
return []
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
@@ -206,6 +186,16 @@ class CodeActAgent(Agent):
content=content,
)
]
elif isinstance(action, CmdRunAction) and action.source == 'user':
content = [
TextContent(text=f'User executed the command:\n{action.command}')
]
return [
Message(
role='user',
content=content,
)
]
return []
def get_observation_message(
@@ -240,15 +230,21 @@ class CodeActAgent(Agent):
"""
message: Message
max_message_chars = self.llm.config.max_message_chars
obs_prefix = 'OBSERVATION:\n'
if isinstance(obs, CmdOutputObservation):
text = obs_prefix + truncate_content(
obs.content + obs.interpreter_details, max_message_chars
)
# if it doesn't have tool call metadata, it was triggered by a user action
if obs.tool_call_metadata is None:
text = truncate_content(
f'\nObserved result of command executed by user:\n{obs.content}',
max_message_chars,
)
else:
text = truncate_content(
obs.content + obs.interpreter_details, max_message_chars
)
text += f'\n[Command finished with exit code {obs.exit_code}]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, IPythonRunCellObservation):
text = obs_prefix + obs.content
text = obs.content
# replace base64 images with a placeholder
splitted = text.split('\n')
for i, line in enumerate(splitted):
@@ -260,22 +256,22 @@ class CodeActAgent(Agent):
text = truncate_content(text, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, FileEditObservation):
text = obs_prefix + truncate_content(str(obs), max_message_chars)
text = truncate_content(str(obs), max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, BrowserOutputObservation):
text = obs.get_agent_obs_text()
message = Message(
role='user',
content=[TextContent(text=obs_prefix + text)],
content=[TextContent(text=text)],
)
elif isinstance(obs, AgentDelegateObservation):
text = obs_prefix + truncate_content(
text = truncate_content(
obs.outputs['content'] if 'content' in obs.outputs else '',
max_message_chars,
)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, ErrorObservation):
text = obs_prefix + truncate_content(obs.content, max_message_chars)
text = truncate_content(obs.content, max_message_chars)
text += '\n[Error occurred in processing last action]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, UserRejectObservation):
@@ -287,19 +283,18 @@ class CodeActAgent(Agent):
# when the LLM tries to return the next message
raise ValueError(f'Unknown observation type: {type(obs)}')
if self.function_calling_active:
# Update the message as tool response properly
if (tool_call_metadata := obs.tool_call_metadata) is not None:
tool_call_id_to_message[tool_call_metadata.tool_call_id] = Message(
role='tool',
content=message.content,
tool_call_id=tool_call_metadata.tool_call_id,
name=tool_call_metadata.function_name,
)
# No need to return the observation message
# because it will be added by get_action_message when all the corresponding
# tool calls in the SAME request are processed
return []
# Update the message as tool response properly
if (tool_call_metadata := obs.tool_call_metadata) is not None:
tool_call_id_to_message[tool_call_metadata.tool_call_id] = Message(
role='tool',
content=message.content,
tool_call_id=tool_call_metadata.tool_call_id,
name=tool_call_metadata.function_name,
)
# No need to return the observation message
# because it will be added by get_action_message when all the corresponding
# tool calls in the SAME request are processed
return []
return [message]
@@ -335,25 +330,14 @@ class CodeActAgent(Agent):
params: dict = {
'messages': self.llm.format_messages_for_llm(messages),
}
if self.function_calling_active:
params['tools'] = self.tools
params['parallel_tool_calls'] = False
else:
params['stop'] = [
'</execute_ipython>',
'</execute_bash>',
'</execute_browse>',
'</file_edit>',
]
params['tools'] = self.tools
if self.mock_function_calling:
params['mock_function_calling'] = True
response = self.llm.completion(**params)
if self.function_calling_active:
actions = codeact_function_calling.response_to_actions(response)
for action in actions:
self.pending_actions.append(action)
return self.pending_actions.popleft()
else:
return self.action_parser.parse(response)
actions = codeact_function_calling.response_to_actions(response)
for action in actions:
self.pending_actions.append(action)
return self.pending_actions.popleft()
def _get_messages(self, state: State) -> list[Message]:
"""Constructs the message history for the LLM conversation.
@@ -484,7 +468,4 @@ class CodeActAgent(Agent):
else:
break
if not self.function_calling_active:
self.prompt_manager.add_turns_left_reminder(messages, state)
return messages

View File

@@ -53,9 +53,6 @@ _IPYTHON_DESCRIPTION = """Run a cell of Python code in an IPython environment.
* The assistant should define variables and import packages before using them.
* The variable defined in the IPython environment will not be available outside the IPython environment (e.g., in terminal).
"""
# We are not using agentskills's file_ops for viewing files now because StrReplaceEditorTool already supports viewing files
# """* Apart from the standard Python library, the assistant can also use the following functions (already imported):
# {AgentSkillsRequirement.documentation}"""
IPythonTool = ChatCompletionToolParam(
type='function',

View File

@@ -1,174 +0,0 @@
{% set MINIMAL_SYSTEM_PREFIX %}
A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions.
[1] The assistant can use a Python environment with <execute_ipython>, e.g.:
<execute_ipython>
print("Hello World!")
</execute_ipython>
[2] The assistant can execute bash commands wrapped with <execute_bash>, e.g. <execute_bash> ls </execute_bash>.
If a bash command returns exit code `-1`, this means the process is not yet finished.
The assistant must then send a second <execute_bash>. The second <execute_bash> can be empty
(which will retrieve any additional logs), or it can contain text to be sent to STDIN of the running process,
or it can contain the text `ctrl+c` to interrupt the process.
For commands that may run indefinitely, the output should be redirected to a file and the command run
in the background, e.g. <execute_bash> python3 app.py > server.log 2>&1 & </execute_bash>
If a command execution result says "Command timed out. Sending SIGINT to the process",
the assistant should retry running the command in the background.
[3] The assistant can edit files using <file_edit> by setting the file path and providing a draft of the new file content. The draft file content does not need to be exactly the same as the existing file content; the assistant may skip some lines and only include the parts that need to be changed.
IMPORTANT: When editing large file (e.g., > 300 lines), the assistant MUST SPECIFY the range of lines to be edited by setting `start` and `end` (1-indexed, both inclusive). For example, `<file_edit path="/path/to/file.txt" start=1 end=-1>` means the assistant will edit the whole file (from line 1 to the end of the file). `start=1` and `end=-1` are the default values, so the assistant can omit them if they are the same as the default values.
BEFORE you start editing, you MUST view the ENTIRE body of the part you want to edit and get the correct begin and end line numbers.
When editing files, the assistant should include comments indicating where the code will not change. For example, use comments like `# no changes before` or `# no changes here` to clearly mark sections of the code that remain unchanged. This helps to provide context and ensure clarity in the edits being made.
Possible cases:
- File too long: When the file to be edited is too long, the assistant should set `start` and `end` (1-indexed, both inclusive) to specify the range of lines to be edited. For example, `<file_edit path="/path/to/file.txt" start=100 end=200>` means the assistant will only edit lines 100 to 200 of `/path/to/file.txt`.
- Append to file: If the assistant wants to append to a file, it should set both `start` and `end` to `-1`.
- File does not exist: If `<file_edit>` is pointing to a file that does not exist, a new file with the exact content will be created.
Important: because line numbers are useful, the assistant should always use the provided functions to search (e.g., `search_dir`) or view the file content (e.g., `open_file`) along with the line numbers. DO NOT use other methods (e.g., `cat`) to view the file content.
**Example 1 (general edit for short files)**
For example, given an existing file `/path/to/file.py` that looks like this:
(this is the end of the file)
1|class MyClass:
2| def __init__(self):
3| self.x = 1
4| self.y = 2
5| self.z = 3
6|
7|print(MyClass().z)
8|print(MyClass().x)
(this is the end of the file)
The assistant wants to edit the file to look like this:
(this is the end of the file)
1|class MyClass:
2| def __init__(self):
3| self.x = 1
4| self.y = 2
5|
6|print(MyClass().y)
(this is the end of the file)
The assistant may produce an edit action like this:
<file_edit path="/path/to/file.txt" start=1 end=-1>
class MyClass:
def __init__(self):
# no changes before
self.y = 2
# self.z is removed
# MyClass().z is removed
print(MyClass().y)
</file_edit>
**Example 2 (append to file for short files)**
For example, given an existing file `/path/to/file.py` that looks like this:
(this is the end of the file)
1|class MyClass:
2| def __init__(self):
3| self.x = 1
4| self.y = 2
5| self.z = 3
6|
7|print(MyClass().z)
8|print(MyClass().x)
(this is the end of the file)
To append the following lines to the file:
```python
print(MyClass().y)
```
The assistant may produce an edit action like this:
<file_edit path="/path/to/file.txt" start=-1 end=-1>
print(MyClass().y)
</file_edit>
**Example 3 (edit for long files)**
Given an existing file `/path/to/file.py` that looks like this:
(1000 more lines above)
1001|class MyClass:
1002| def __init__(self):
1003| self.x = 1
1004| self.y = 2
1005| self.z = 3
1006|
1007|print(MyClass().z)
1008|print(MyClass().x)
(2000 more lines below)
The assistant wants to edit the file to look like this:
(1000 more lines above)
1001|class MyClass:
1002| def __init__(self):
1003| self.x = 1
1004| self.y = 2
1005|
1006|print(MyClass().y)
(2000 more lines below)
The assistant may produce an edit action like this:
<file_edit path="/path/to/file.txt" start=1001 end=1008>
class MyClass:
def __init__(self):
# no changes before
self.y = 2
# self.z is removed
# MyClass().z is removed
print(MyClass().y)
</file_edit>
{% endset %}
{% set BROWSING_PREFIX %}
The assistant can browse the Internet with <execute_browse> and </execute_browse>.
For example, <execute_browse> Tell me the usa's president using google search </execute_browse>.
Or <execute_browse> Tell me what is in http://example.com </execute_browse>.
{% endset %}
{% set PIP_INSTALL_PREFIX %}
The assistant can install Python packages using the %pip magic command in an IPython environment by using the following syntax: <execute_ipython> %pip install [package needed] </execute_ipython> and should always import packages and define variables before starting to use them.
{% endset %}
{% set SYSTEM_PREFIX = MINIMAL_SYSTEM_PREFIX + BROWSING_PREFIX + PIP_INSTALL_PREFIX %}
{% set COMMAND_DOCS %}
Apart from the standard Python library, the assistant can also use the following functions (already imported) in <execute_ipython> environment:
{{ agent_skills_docs }}
IMPORTANT:
- `open_file` only returns the first 100 lines of the file by default! The assistant MUST use `scroll_down` repeatedly to read the full file BEFORE making edits!
- Indentation is important and code that is not indented correctly will fail and require fixing before it can be run.
- Any code issued should be less than 50 lines to avoid context being cut off!
{% endset %}
{% set SYSTEM_SUFFIX %}
Responses should be concise.
The assistant should attempt fewer things at a time instead of putting too many commands OR too much code in one "execute" block.
Include ONLY ONE <execute_ipython>, <execute_bash>, or <execute_browse> per response, unless the assistant is finished with the task or needs more input or action from the user in order to proceed.
If the assistant is finished with the task you MUST include <finish></finish> in your response.
IMPORTANT: Execute code using <execute_ipython>, <execute_bash>, or <execute_browse> whenever possible.
The assistant should utilize full file paths and the `pwd` command to prevent path-related errors.
The assistant MUST NOT apologize to the user or thank the user after running commands or editing files. It should only address the user in response to an explicit message from the user, or to ask for more information.
The assistant MUST NOT push any changes to GitHub unless explicitly requested to do so.
The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior, or
to describe precisely how to apply proposed edits. Comments about applying edits should always have blank lines above
and below.
{% endset %}
{# Combine all parts without newlines between them #}
{{ SYSTEM_PREFIX -}}
{{- COMMAND_DOCS -}}
{{- SYSTEM_SUFFIX }}

View File

@@ -1,219 +0,0 @@
{% set DEFAULT_EXAMPLE %}
--- START OF EXAMPLE ---
USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
ASSISTANT:
Sure! Let me first check the current directory:
<execute_bash>
ls
</execute_bash>
USER:
OBSERVATION:
openhands@runtime:~/workspace$
ASSISTANT:
There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
<file_edit path="/workspace/app.py" start=-1 end=-1>
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
numbers = list(range(1, 11))
return str(numbers)
if __name__ == '__main__':
app.run(port=5000)
</file_edit>
USER:
OBSERVATION:
[New file /workspace/app.py is created.]
(begin of changes)
--- /workspace/app.py
+++ /workspace/app.py
@@ -1 +1,10 @@
+from flask import Flask
+app = Flask(__name__)
+@app.route('/')
+def index():
+ numbers = list(range(1, 11))
+ return str(numbers)
+
+if __name__ == '__main__':
+ app.run(port=5000)
(end of changes)
ASSISTANT:
I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you:
<execute_bash>
python3 app.py > server.log 2>&1 &
</execute_bash>
USER:
OBSERVATION:
[1] 121[1]+ Exit 1 python3 app.py > server.log 2>&1
ASSISTANT: Looks like the server is running with PID 121 then crashed. Let me check the server log:
<execute_bash>
cat server.log
</execute_bash>
USER:
OBSERVATION:
Traceback (most recent call last):
File "/workspace/app.py", line 2, in <module>
from flask import Flask
ModuleNotFoundError: No module named 'flask'
ASSISTANT:
It seems that Flask is not installed. Let me install Flask for you:
<execute_bash>
pip install flask
</execute_bash>
USER:
OBSERVATION:
Defaulting to user installation because normal site-packages is not writeable
Collecting flask
Using cached flask-3.0.3-py3-none-any.whl (101 kB)
Collecting blinker>=1.6.2
Using cached blinker-1.7.0-py3-none-any.whl (13 kB)
Collecting Werkzeug>=3.0.0
Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB)
Collecting click>=8.1.3
Using cached click-8.1.7-py3-none-any.whl (97 kB)
Collecting itsdangerous>=2.1.2
Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB)
Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3)
Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5)
Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask
Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0
ASSISTANT:
Now that Flask is installed, let me run the Python file again:
<execute_bash>
python3 app.py > server.log 2>&1 &
</execute_bash>
USER:
OBSERVATION:
[1] 124
ASSISTANT:
Let me check the server log again:
<execute_bash>
cat server.log
</execute_bash>
USER:
OBSERVATION:
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
ASSISTANT:
The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. If you have any further questions, feel free to ask!
USER: Now browse the newly started server's homepage and show me the content.
ASSISTANT:
Sure! Let me browse the server's homepage at http://127.0.0.1:5000:
<execute_browse>
Get the content on "http://127.0.0.1:5000"
</execute_browse>
USER:
OBSERVATION:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
ASSISTANT:
The content of the server's homepage is "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]". If you have any further questions, feel free to ask!
USER: Now kill the server, make it display the numbers in a table format.
ASSISTANT:
Sure! Let me stop the server first:
<execute_bash>
kill 124
</execute_bash>
USER:
OBSERVATION:
[1]+ Terminated python3 app.py > server.log 2>&1
ASSISTANT:
The server has been stopped. Let me open the Python file and modify it.
<execute_ipython>
open_file('app.py')
</execute_ipython>
USER:
[File: /workspace/app.py (10 lines total)]
(this is the beginning of the file)
1|from flask import Flask
2|app = Flask(__name__)
3|
4|@app.route('/')
5|def index():
6| numbers = list(range(1, 11))
7| return str(numbers)
8|
9|if __name__ == '__main__':
10| app.run(port=5000)
(this is the end of the file)
ASSISTANT:
I should edit the file to display the numbers in a table format. I should include correct indentation. Let me update the file:
<file_edit path="/workspace/app.py" start=4 end=7>
@app.route('/')
def index():
numbers = list(range(1, 11))
ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
return ret
</file_edit>
USER:
Observation:
[Edited existing file /workspace/app.py]
[File: /workspace/app.py (10 lines total after edit)]
(this is the beginning of the file)
1|from flask import Flask
2|app = Flask(__name__)
3|
4|@app.route('/')
5|def index():
6| numbers = list(range(1, 11))
7| ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
8| return ret
9|
10|if __name__ == '__main__':
11| app.run(port=5000)
(this is the end of the file)
[File updated (edited at line 7). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]
ASSISTANT:
Running the updated file:
<execute_bash>
python3 app.py > server.log 2>&1 &
</execute_bash>
USER:
Observation:
[1] 126
ASSISTANT:
The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests!
<finish></finish>
--- END OF EXAMPLE ---
{% endset %}
Here is an example of how you can interact with the environment for task solving:
{{ DEFAULT_EXAMPLE }}
NOW, LET'S START!

View File

@@ -4,4 +4,3 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>

View File

@@ -5,12 +5,14 @@ import traceback
from typing import Callable, ClassVar, Type
import litellm
from litellm.exceptions import ContextWindowExceededError
from openhands.controller.agent import Agent
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 (
FunctionCallValidationError,
LLMMalformedActionError,
LLMNoActionError,
LLMResponseError,
@@ -63,6 +65,7 @@ class AgentController:
parent: 'AgentController | None' = None
delegate: 'AgentController | None' = None
_pending_action: Action | None = None
_closed: bool = False
filter_out: ClassVar[tuple[type[Event], ...]] = (
NullAction,
NullObservation,
@@ -158,6 +161,7 @@ class AgentController:
# unsubscribe from the event stream
self.event_stream.unsubscribe(EventStreamSubscriber.AGENT_CONTROLLER, self.id)
self._closed = True
def log(self, level: str, message: str, extra: dict | None = None):
"""Logs a message to the agent controller's logger.
@@ -192,6 +196,8 @@ class AgentController:
self.log('info', 'Starting step loop...')
while should_continue():
if self._closed:
break
try:
await self._step()
except asyncio.CancelledError:
@@ -477,7 +483,12 @@ class AgentController:
action = self.agent.step(self.state)
if action is None:
raise LLMNoActionError('No action was returned')
except (LLMMalformedActionError, LLMNoActionError, LLMResponseError) as e:
except (
LLMMalformedActionError,
LLMNoActionError,
LLMResponseError,
FunctionCallValidationError,
) as e:
self.event_stream.add_event(
ErrorObservation(
content=str(e),
@@ -485,6 +496,15 @@ class AgentController:
EventSource.AGENT,
)
return
except ContextWindowExceededError:
# When context window is exceeded, keep roughly half of agent interactions
self.state.history = self._apply_conversation_window(self.state.history)
# Save the ID of the first event in our truncated history for future reloading
if self.state.history:
self.state.start_id = self.state.history[0].id
# Don't add error event - let the agent retry with reduced context
return
if action.runnable:
if self.state.confirmation_mode and (
@@ -659,6 +679,12 @@ class AgentController:
- For delegate events (between AgentDelegateAction and AgentDelegateObservation):
- Excludes all events between the action and observation
- Includes the delegate action and observation themselves
The history is loaded in two parts if truncation_id is set:
1. First user message from start_id onwards
2. Rest of history from truncation_id to the end
Otherwise loads normally from start_id.
"""
# define range of events to fetch
@@ -680,8 +706,33 @@ class AgentController:
self.state.history = []
return
# Get all events, filtering out backend events and hidden events
events = list(
events: list[Event] = []
# If we have a truncation point, get first user message and then rest of history
if hasattr(self.state, 'truncation_id') and self.state.truncation_id > 0:
# Find first user message from stream
first_user_msg = next(
(
e
for e in self.event_stream.get_events(
start_id=start_id,
end_id=end_id,
reverse=False,
filter_out_type=self.filter_out,
filter_hidden=True,
)
if isinstance(e, MessageAction) and e.source == EventSource.USER
),
None,
)
if first_user_msg:
events.append(first_user_msg)
# the rest of the events are from the truncation point
start_id = self.state.truncation_id
# Get rest of history
events_to_add = list(
self.event_stream.get_events(
start_id=start_id,
end_id=end_id,
@@ -690,6 +741,7 @@ class AgentController:
filter_hidden=True,
)
)
events.extend(events_to_add)
# Find all delegate action/observation pairs
delegate_ranges: list[tuple[int, int]] = []
@@ -744,6 +796,92 @@ class AgentController:
# make sure history is in sync
self.state.start_id = start_id
def _apply_conversation_window(self, events: list[Event]) -> list[Event]:
"""Cuts history roughly in half when context window is exceeded, preserving action-observation pairs
and ensuring the first user message is always included.
The algorithm:
1. Cut history in half
2. Check first event in new history:
- If Observation: find and include its Action
- If MessageAction: ensure its related Action-Observation pair isn't split
3. Always include the first user message
Args:
events: List of events to filter
Returns:
Filtered list of events keeping newest half while preserving pairs
"""
if not events:
return events
# Find first user message - we'll need to ensure it's included
first_user_msg = next(
(
e
for e in events
if isinstance(e, MessageAction) and e.source == EventSource.USER
),
None,
)
# cut in half
mid_point = max(1, len(events) // 2)
kept_events = events[mid_point:]
# Handle first event in truncated history
if kept_events:
i = 0
while i < len(kept_events):
first_event = kept_events[i]
if isinstance(first_event, Observation) and first_event.cause:
# Find its action and include it
matching_action = next(
(
e
for e in reversed(events[:mid_point])
if isinstance(e, Action) and e.id == first_event.cause
),
None,
)
if matching_action:
kept_events = [matching_action] + kept_events
else:
self.log(
'warning',
f'Found Observation without matching Action at id={first_event.id}',
)
# drop this observation
kept_events = kept_events[1:]
break
elif isinstance(first_event, MessageAction) or (
isinstance(first_event, Action)
and first_event.source == EventSource.USER
):
# if it's a message action or a user action, keep it and continue to find the next event
i += 1
continue
else:
# if it's an action with source == EventSource.AGENT, we're good
break
# Save where to continue from in next reload
if kept_events:
self.state.truncation_id = kept_events[0].id
# Ensure first user message is included
if first_user_msg and first_user_msg not in kept_events:
kept_events = [first_user_msg] + kept_events
# start_id points to first user message
if first_user_msg:
self.state.start_id = first_user_msg.id
return kept_events
def _is_stuck(self):
"""Checks if the agent or its delegate is stuck in a loop.

View File

@@ -92,6 +92,8 @@ class State:
# start_id and end_id track the range of events in history
start_id: int = -1
end_id: int = -1
# truncation_id tracks where to load history after context window truncation
truncation_id: int = -1
almost_stuck: int = 0
delegates: dict[tuple[int, int], tuple[str, str]] = field(default_factory=dict)
# NOTE: This will never be used by the controller, but it can be used by different

View File

@@ -20,7 +20,6 @@ class AgentConfig:
disabled_microagents: A list of microagents to disable. Default is None.
"""
function_calling: bool = True
codeact_enable_browsing: bool = True
codeact_enable_llm_editor: bool = False
codeact_enable_jupyter: bool = True

View File

@@ -94,3 +94,23 @@ class CloudFlareBlockageError(Exception):
"""Exception raised when a request is blocked by CloudFlare."""
pass
class FunctionCallConversionError(Exception):
"""Exception raised when FunctionCallingConverter failed to convert a non-function call message to a function call message.
This typically happens when there's a malformed message (e.g., missing <function=...> tags). But not due to LLM output.
"""
def __init__(self, message):
super().__init__(message)
class FunctionCallValidationError(Exception):
"""Exception raised when FunctionCallingConverter failed to validate a function call message.
This typically happens when the LLM outputs unrecognized function call / parameter names / values.
"""
def __init__(self, message):
super().__init__(message)

View File

@@ -35,8 +35,8 @@ class FakeUserResponseFunc(Protocol):
def __call__(
self,
state: State,
encapsulate_solution: bool = ...,
try_parse: Callable[[Action], str] = ...,
encapsulate_solution: bool = False,
try_parse: Callable[[Action | None], str] | None = None,
) -> str: ...

View File

@@ -72,7 +72,12 @@ class Message(BaseModel):
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
# NOTE: remove this when litellm or providers support the new API
if self.cache_enabled or self.vision_enabled or self.tool_call_id is not None:
if (
self.cache_enabled
or self.vision_enabled
or self.tool_call_id is not None
or self.tool_calls is not None
):
return self._list_serializer()
return self._string_serializer()

View File

@@ -24,6 +24,7 @@ class MessageAction(Action):
@images_urls.setter
def images_urls(self, value):
self.image_urls = value
def __str__(self) -> str:
ret = f'**MessageAction** (source={self.source})\n'
ret += f'CONTENT: {self.content}'

View File

@@ -69,7 +69,7 @@ def action_from_dict(action: dict) -> Action:
# images_urls has been renamed to image_urls
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')
try:
decoded_action = action_class(**args)
if 'timeout' in action:

View File

@@ -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']

View File

@@ -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

View File

@@ -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',
)
]

View File

@@ -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
]

View File

@@ -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

View File

@@ -1,3 +0,0 @@
from .cmd import check_tool_installed, run_cmd
__all__ = ['run_cmd', 'check_tool_installed']

View File

@@ -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

View File

@@ -0,0 +1,796 @@
"""Convert function calling messages to non-function calling messages and vice versa.
This will inject prompts so that models that doesn't support function calling
can still be used with function calling agents.
We follow format from: https://docs.litellm.ai/docs/completion/function_call
"""
import copy
import json
import re
from typing import Iterable
from litellm import ChatCompletionToolParam
from openhands.core.exceptions import (
FunctionCallConversionError,
FunctionCallValidationError,
)
# Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b
SYSTEM_PROMPT_SUFFIX_TEMPLATE = """
You have access to the following functions:
{description}
If you choose to call a function ONLY reply in the following format with NO suffix:
<function=example_function_name>
<parameter=example_parameter_1>value_1</parameter>
<parameter=example_parameter_2>
This is the value for the second parameter
that can span
multiple lines
</parameter>
</function>
<IMPORTANT>
Reminder:
- Function calls MUST follow the specified format, start with <function= and end with </function>
- Required parameters MUST be specified
- Only call one function at a time
- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after.
- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls
"""
STOP_WORDS = ['</function']
# NOTE: we need to make sure this example is always in-sync with the tool interface designed in openhands/agenthub/codeact_agent/function_calling.py
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = """
Here's a running example of how to perform a task with the provided tools.
--------------------- START OF EXAMPLE ---------------------
USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
ASSISTANT: Sure! Let me first check the current directory:
<function=execute_bash>
<parameter=command>
pwd && ls
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
/workspace
openhands@runtime:~/workspace$
ASSISTANT: There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
<function=str_replace_editor>
<parameter=command>create</parameter>
<parameter=path>/workspace/app.py</parameter>
<parameter=file_text>
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
numbers = list(range(1, 11))
return str(numbers)
if __name__ == '__main__':
app.run(port=5000)
</parameter>
</function>
USER: EXECUTION RESULT of [str_replace_editor]:
File created successfully at: /workspace/app.py
ASSISTANT: I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you:
<function=execute_bash>
<parameter=command>
python3 app.py > server.log 2>&1 &
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
[1] 121
[1]+ Exit 1 python3 app.py > server.log 2>&1
ASSISTANT: Looks like the server is running with PID 121 then crashed. Let me check the server log:
<function=execute_bash>
<parameter=command>
cat server.log
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
Traceback (most recent call last):
File "/workspace/app.py", line 2, in <module>
from flask import Flask
ModuleNotFoundError: No module named 'flask'
ASSISTANT: Looks like the server crashed because the `flask` module is not installed. Let me install the `flask` module for you:
<function=execute_bash>
<parameter=command>
pip3 install flask
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
Defaulting to user installation because normal site-packages is not writeable
Collecting flask
Using cached flask-3.0.3-py3-none-any.whl (101 kB)
Collecting blinker>=1.6.2
Using cached blinker-1.7.0-py3-none-any.whl (13 kB)
Collecting Werkzeug>=3.0.0
Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB)
Collecting click>=8.1.3
Using cached click-8.1.7-py3-none-any.whl (97 kB)
Collecting itsdangerous>=2.1.2
Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB)
Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3)
Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5)
Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask
Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0
ASSISTANT: The `flask` module is installed successfully. Let me run the Python file again:
<function=execute_bash>
<parameter=command>
python3 app.py > server.log 2>&1 &
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
[1] 124
ASSISTANT:
Let me check the server log again:
<function=execute_bash>
<parameter=command>
cat server.log
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
ASSISTANT:
The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. If you have any further questions, feel free to ask!
USER: Now kill the server, make it display the numbers in a table format.
ASSISTANT:
Sure! Let me stop the server first:
<function=execute_bash>
<parameter=command>
kill 124
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
[1]+ Terminated python3 app.py > server.log 2>&1
ASSISTANT:
Now let me display the numbers in a table format:
<function=str_replace_editor>
<parameter=command>str_replace</parameter>
<parameter=path>/workspace/app.py</parameter>
<parameter=old_str>return str(numbers)</parameter>
<parameter=new_str>return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'</parameter>
</function>
USER: EXECUTION RESULT of [str_replace_editor]:
The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
3
4 @app.route('/')
5 def index():
6 numbers = list(range(1, 11))
7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
8
9 if __name__ == '__main__':
10 app.run(port=5000)
Review the changes and make sure they are as expected. Edit the file again if necessary.
ASSISTANT:
Running the updated file:
<function=execute_bash>
<parameter=command>
<parameter=command>
python3 app.py > server.log 2>&1 &
</parameter>
</function>
USER: EXECUTION RESULT of [execute_bash]:
[1] 126
ASSISTANT:
The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests!
<function=finish>
</function>
--------------------- END OF EXAMPLE ---------------------
Do NOT assume the environment is the same as in the example above.
--------------------- NEW TASK DESCRIPTION ---------------------
""".lstrip()
IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """
--------------------- END OF NEW TASK DESCRIPTION ---------------------
PLEASE follow the format strictly! PLEASE EMIT ONE AND ONLY ONE FUNCTION CALL PER MESSAGE.
""".lstrip()
# Regex patterns for function call parsing
FN_REGEX_PATTERN = r'<function=([^>]+)>\n(.*?)</function>'
FN_PARAM_REGEX_PATTERN = r'<parameter=([^>]+)>(.*?)</parameter>'
# Add new regex pattern for tool execution results
TOOL_RESULT_REGEX_PATTERN = r'EXECUTION RESULT of \[(.*?)\]:\n(.*)'
def convert_tool_call_to_string(tool_call: dict) -> str:
"""Convert tool call to content in string format."""
if 'function' not in tool_call:
raise FunctionCallConversionError("Tool call must contain 'function' key.")
if 'id' not in tool_call:
raise FunctionCallConversionError("Tool call must contain 'id' key.")
if 'type' not in tool_call:
raise FunctionCallConversionError("Tool call must contain 'type' key.")
if tool_call['type'] != 'function':
raise FunctionCallConversionError("Tool call type must be 'function'.")
ret = f"<function={tool_call['function']['name']}>\n"
try:
args = json.loads(tool_call['function']['arguments'])
except json.JSONDecodeError as e:
raise FunctionCallConversionError(
f"Failed to parse arguments as JSON. Arguments: {tool_call['function']['arguments']}"
) from e
for param_name, param_value in args.items():
is_multiline = isinstance(param_value, str) and '\n' in param_value
ret += f'<parameter={param_name}>'
if is_multiline:
ret += '\n'
ret += f'{param_value}'
if is_multiline:
ret += '\n'
ret += '</parameter>\n'
ret += '</function>'
return ret
def convert_tools_to_description(tools: list[dict]) -> str:
ret = ''
for i, tool in enumerate(tools):
assert tool['type'] == 'function'
fn = tool['function']
if i > 0:
ret += '\n'
ret += f"---- BEGIN FUNCTION #{i+1}: {fn['name']} ----\n"
ret += f"Description: {fn['description']}\n"
if 'parameters' in fn:
ret += 'Parameters:\n'
properties = fn['parameters'].get('properties', {})
required_params = set(fn['parameters'].get('required', []))
for j, (param_name, param_info) in enumerate(properties.items()):
# Indicate required/optional in parentheses with type
is_required = param_name in required_params
param_status = 'required' if is_required else 'optional'
param_type = param_info.get('type', 'string')
# Get parameter description
desc = param_info.get('description', 'No description provided')
# Handle enum values if present
if 'enum' in param_info:
enum_values = ', '.join(f'`{v}`' for v in param_info['enum'])
desc += f'\nAllowed values: [{enum_values}]'
ret += (
f' ({j+1}) {param_name} ({param_type}, {param_status}): {desc}\n'
)
else:
ret += 'No parameters are required for this function.\n'
ret += f'---- END FUNCTION #{i+1} ----\n'
return ret
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)
formatted_tools = convert_tools_to_description(tools)
system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format(
description=formatted_tools
)
converted_messages = []
first_user_message_encountered = False
for message in messages:
role, content = message['role'], message['content']
if content is None:
content = ''
# 1. SYSTEM MESSAGES
# append system prompt suffix to content
if role == 'system':
if isinstance(content, str):
content += system_prompt_suffix
elif isinstance(content, list):
if content and content[-1]['type'] == 'text':
content[-1]['text'] += system_prompt_suffix
else:
content.append({'type': 'text', 'text': system_prompt_suffix})
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
converted_messages.append({'role': 'system', 'content': content})
# 2. USER MESSAGES (no change)
elif role == 'user':
# Add in-context learning example for the first user message
if not first_user_message_encountered and add_in_context_learning_example:
first_user_message_encountered = True
# Check tools
if not (
tools
and len(tools) > 0
and any(
(
tool['type'] == 'function'
and tool['function']['name'] == 'execute_bash'
and 'command'
in tool['function']['parameters']['properties']
)
for tool in tools
)
and any(
(
tool['type'] == 'function'
and tool['function']['name'] == 'str_replace_editor'
and 'path' in tool['function']['parameters']['properties']
and 'file_text'
in tool['function']['parameters']['properties']
and 'old_str'
in tool['function']['parameters']['properties']
and 'new_str'
in tool['function']['parameters']['properties']
)
for tool in tools
)
):
raise FunctionCallConversionError(
'The currently provided tool set are NOT compatible with the in-context learning example for FnCall to Non-FnCall conversion. '
'Please update your tool set OR the in-context learning example in openhands/llm/fn_call_converter.py'
)
# add in-context learning example
if isinstance(content, str):
content = (
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
+ content
+ IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
)
elif isinstance(content, list):
if content and content[0]['type'] == 'text':
content[0]['text'] = (
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
+ content[0]['text']
+ IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
)
else:
content = (
[
{
'type': 'text',
'text': IN_CONTEXT_LEARNING_EXAMPLE_PREFIX,
}
]
+ content
+ [
{
'type': 'text',
'text': IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX,
}
]
)
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
converted_messages.append(
{
'role': 'user',
'content': content,
}
)
# 3. ASSISTANT MESSAGES
# - 3.1 no change if no function call
# - 3.2 change if function call
elif role == 'assistant':
if 'tool_calls' in message and message['tool_calls'] is not None:
if len(message['tool_calls']) != 1:
raise FunctionCallConversionError(
f'Expected exactly one tool call in the message. More than one tool call is not supported. But got {len(message["tool_calls"])} tool calls. Content: {content}'
)
try:
tool_content = convert_tool_call_to_string(message['tool_calls'][0])
except FunctionCallConversionError as e:
raise FunctionCallConversionError(
f'Failed to convert tool call to string. Raw messages: {json.dumps(messages, indent=2)}'
) from e
if isinstance(content, str):
content += '\n\n' + tool_content
content = content.lstrip()
elif isinstance(content, list):
if content and content[-1]['type'] == 'text':
content[-1]['text'] += '\n\n' + tool_content
content[-1]['text'] = content[-1]['text'].lstrip()
else:
content.append({'type': 'text', 'text': tool_content})
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
converted_messages.append({'role': 'assistant', 'content': content})
# 4. TOOL MESSAGES (tool outputs)
elif role == 'tool':
# Convert tool result as assistant message
prefix = f'EXECUTION RESULT of [{message["name"]}]:\n'
# and omit "tool_call_id" AND "name"
if isinstance(content, str):
content = prefix + content
elif isinstance(content, list):
if content and content[-1]['type'] == 'text':
content[-1]['text'] = prefix + content[-1]['text']
else:
content = [{'type': 'text', 'text': prefix}] + content
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
converted_messages.append({'role': 'user', 'content': content})
else:
raise FunctionCallConversionError(
f'Unexpected role {role}. Expected system, user, assistant or tool.'
)
return converted_messages
def _extract_and_validate_params(
matching_tool: dict, param_matches: Iterable[re.Match], fn_name: str
) -> dict:
params = {}
# Parse and validate parameters
required_params = set()
if 'parameters' in matching_tool and 'required' in matching_tool['parameters']:
required_params = set(matching_tool['parameters'].get('required', []))
allowed_params = set()
if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']:
allowed_params = set(matching_tool['parameters']['properties'].keys())
param_name_to_type = {}
if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']:
param_name_to_type = {
name: val.get('type', 'string')
for name, val in matching_tool['parameters']['properties'].items()
}
# Collect parameters
found_params = set()
for param_match in param_matches:
param_name = param_match.group(1)
param_value = param_match.group(2).strip()
# Validate parameter is allowed
if allowed_params and param_name not in allowed_params:
raise FunctionCallValidationError(
f"Parameter '{param_name}' is not allowed for function '{fn_name}'. "
f'Allowed parameters: {allowed_params}'
)
# Validate and convert parameter type
# supported: string, integer, array
if param_name in param_name_to_type:
if param_name_to_type[param_name] == 'integer':
try:
param_value = int(param_value)
except ValueError:
raise FunctionCallValidationError(
f"Parameter '{param_name}' is expected to be an integer."
)
elif param_name_to_type[param_name] == 'array':
try:
param_value = json.loads(param_value)
except json.JSONDecodeError:
raise FunctionCallValidationError(
f"Parameter '{param_name}' is expected to be an array."
)
else:
# string
pass
# Enum check
if 'enum' in matching_tool['parameters']['properties'][param_name]:
if (
param_value
not in matching_tool['parameters']['properties'][param_name]['enum']
):
raise FunctionCallValidationError(
f"Parameter '{param_name}' is expected to be one of {matching_tool['parameters']['properties'][param_name]['enum']}."
)
params[param_name] = param_value
found_params.add(param_name)
# Check all required parameters are present
missing_params = required_params - found_params
if missing_params:
raise FunctionCallValidationError(
f"Missing required parameters for function '{fn_name}': {missing_params}"
)
return params
def _fix_stopword(content: str) -> str:
"""Fix the issue when some LLM would NOT return the stopword."""
if '<function=' in content and content.count('<function=') == 1:
if content.endswith('</'):
content = content.rstrip() + 'function>'
else:
content = content + '\n</function>'
return content
def convert_non_fncall_messages_to_fncall_messages(
messages: list[dict],
tools: list[ChatCompletionToolParam],
) -> list[dict]:
"""Convert non-function calling messages back to function calling messages."""
messages = copy.deepcopy(messages)
formatted_tools = convert_tools_to_description(tools)
system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format(
description=formatted_tools
)
converted_messages = []
tool_call_counter = 1 # Counter for tool calls
first_user_message_encountered = False
for message in messages:
role, content = message['role'], message['content']
content = content or '' # handle cases where content is None
# For system messages, remove the added suffix
if role == 'system':
if isinstance(content, str):
# Remove the suffix if present
content = content.split(system_prompt_suffix)[0]
elif isinstance(content, list):
if content and content[-1]['type'] == 'text':
# Remove the suffix from the last text item
content[-1]['text'] = content[-1]['text'].split(
system_prompt_suffix
)[0]
converted_messages.append({'role': 'system', 'content': content})
# Skip user messages (no conversion needed)
elif role == 'user':
# Check & replace in-context learning example
if not first_user_message_encountered:
first_user_message_encountered = True
if isinstance(content, str):
content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, '')
content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '')
elif isinstance(content, list):
for item in content:
if item['type'] == 'text':
item['text'] = item['text'].replace(
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, ''
)
item['text'] = item['text'].replace(
IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, ''
)
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
# Check for tool execution result pattern
if isinstance(content, str):
tool_result_match = re.search(
TOOL_RESULT_REGEX_PATTERN, content, re.DOTALL
)
elif isinstance(content, list):
tool_result_match = next(
(
_match
for item in content
if item.get('type') == 'text'
and (
_match := re.search(
TOOL_RESULT_REGEX_PATTERN, item['text'], re.DOTALL
)
)
),
None,
)
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
if tool_result_match:
if not (
isinstance(content, str)
or (
isinstance(content, list)
and len(content) == 1
and content[0].get('type') == 'text'
)
):
raise FunctionCallConversionError(
f'Expected str or list with one text item when tool result is present in the message. Content: {content}'
)
tool_name = tool_result_match.group(1)
tool_result = tool_result_match.group(2).strip()
# Convert to tool message format
converted_messages.append(
{
'role': 'tool',
'name': tool_name,
'content': [{'type': 'text', 'text': tool_result}]
if isinstance(content, list)
else tool_result,
'tool_call_id': f'toolu_{tool_call_counter-1:02d}', # Use last generated ID
}
)
else:
converted_messages.append({'role': 'user', 'content': content})
# Handle assistant messages
elif role == 'assistant':
if isinstance(content, str):
content = _fix_stopword(content)
fn_match = re.search(FN_REGEX_PATTERN, content, re.DOTALL)
elif isinstance(content, list):
if content and content[-1]['type'] == 'text':
content[-1]['text'] = _fix_stopword(content[-1]['text'])
fn_match = re.search(
FN_REGEX_PATTERN, content[-1]['text'], re.DOTALL
)
else:
fn_match = None
fn_match_exists = any(
item.get('type') == 'text'
and re.search(FN_REGEX_PATTERN, item['text'], re.DOTALL)
for item in content
)
if fn_match_exists and not fn_match:
raise FunctionCallConversionError(
f'Expecting function call in the LAST index of content list. But got content={content}'
)
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
if fn_match:
fn_name = fn_match.group(1)
fn_body = fn_match.group(2)
matching_tool = next(
(
tool['function']
for tool in tools
if tool['type'] == 'function'
and tool['function']['name'] == fn_name
),
None,
)
# Validate function exists in tools
if not matching_tool:
raise FunctionCallValidationError(
f"Function '{fn_name}' not found in available tools: {[tool['function']['name'] for tool in tools if tool['type'] == 'function']}"
)
# Parse parameters
param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL)
params = _extract_and_validate_params(
matching_tool, param_matches, fn_name
)
# Create tool call with unique ID
tool_call_id = f'toolu_{tool_call_counter:02d}'
tool_call = {
'index': 1, # always 1 because we only support **one tool call per message**
'id': tool_call_id,
'type': 'function',
'function': {'name': fn_name, 'arguments': json.dumps(params)},
}
tool_call_counter += 1 # Increment counter
# Remove the function call part from content
if isinstance(content, list):
assert content and content[-1]['type'] == 'text'
content[-1]['text'] = (
content[-1]['text'].split('<function=')[0].strip()
)
elif isinstance(content, str):
content = content.split('<function=')[0].strip()
else:
raise FunctionCallConversionError(
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
)
converted_messages.append(
{'role': 'assistant', 'content': content, 'tool_calls': [tool_call]}
)
else:
# No function call, keep message as is
converted_messages.append(message)
else:
raise FunctionCallConversionError(
f'Unexpected role {role}. Expected system, user, or assistant in non-function calling messages.'
)
return converted_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']
if role == 'assistant':
if message.get('tool_calls') and len(message['tool_calls']) > 1:
# handle multiple tool calls by breaking them into multiple messages
for i, tool_call in enumerate(message['tool_calls']):
pending_tool_calls[tool_call['id']] = {
'role': 'assistant',
'content': content if i == 0 else '',
'tool_calls': [tool_call],
}
else:
converted_messages.append(message)
elif role == 'tool':
if message['tool_call_id'] in pending_tool_calls:
# remove the tool call from the pending list
_tool_call_message = pending_tool_calls.pop(message['tool_call_id'])
converted_messages.append(_tool_call_message)
# add the tool result
converted_messages.append(message)
else:
assert (
len(pending_tool_calls) == 0
), f'Found pending tool calls but not found in pending list: {pending_tool_calls=}'
converted_messages.append(message)
else:
assert (
len(pending_tool_calls) == 0
), f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}'
converted_messages.append(message)
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=}'
)
return converted_messages

View File

@@ -12,6 +12,7 @@ from openhands.core.config import LLMConfig
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from litellm import Message as LiteLLMMessage
from litellm import ModelInfo, PromptTokensDetails
from litellm import completion as litellm_completion
from litellm import completion_cost as litellm_completion_cost
@@ -28,6 +29,11 @@ from openhands.core.exceptions import CloudFlareBlockageError
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.llm.debug_mixin import DebugMixin
from openhands.llm.fn_call_converter import (
STOP_WORDS,
convert_fncall_messages_to_non_fncall_messages,
convert_non_fncall_messages_to_fncall_messages,
)
from openhands.llm.metrics import Metrics
from openhands.llm.retry_mixin import RetryMixin
@@ -56,11 +62,12 @@ CACHE_PROMPT_SUPPORTED_MODELS = [
# function calling supporting models
FUNCTION_CALLING_SUPPORTED_MODELS = [
'claude-3-5-sonnet',
'claude-3-5-sonnet-20240620',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'gpt-4o',
'gpt-4o-mini',
'gpt-4o',
]
@@ -136,6 +143,9 @@ class LLM(RetryMixin, DebugMixin):
drop_params=self.config.drop_params,
)
with warnings.catch_warnings():
warnings.simplefilter('ignore')
self.init_model_info()
if self.vision_is_active():
logger.debug('LLM: model has vision enabled')
if self.is_caching_prompt_active():
@@ -143,7 +153,7 @@ class LLM(RetryMixin, DebugMixin):
if self.is_function_calling_active():
logger.debug('LLM: model supports function calling')
completion_unwrapped = self._completion
self._completion_unwrapped = self._completion
@self.retry_decorator(
num_retries=self.config.num_retries,
@@ -154,8 +164,11 @@ class LLM(RetryMixin, DebugMixin):
)
def wrapper(*args, **kwargs):
"""Wrapper for the litellm completion function. Logs the input and output of the completion function."""
self.init_model_info()
from openhands.core.utils import json
messages: list[dict[str, Any]] | dict[str, Any] = []
mock_function_calling = kwargs.pop('mock_function_calling', False)
# some callers might send the model and messages directly
# litellm allows positional args, like completion(model, messages, **kwargs)
@@ -174,6 +187,18 @@ class LLM(RetryMixin, DebugMixin):
# ensure we work with a list of messages
messages = messages if isinstance(messages, list) else [messages]
original_fncall_messages = copy.deepcopy(messages)
mock_fncall_tools = None
if mock_function_calling:
assert (
'tools' in kwargs
), "'tools' must be in kwargs when mock_function_calling is True"
messages = convert_fncall_messages_to_non_fncall_messages(
messages, kwargs['tools']
)
kwargs['messages'] = messages
kwargs['stop'] = STOP_WORDS
mock_fncall_tools = kwargs.pop('tools')
# if we have no messages, something went very wrong
if not messages:
@@ -193,7 +218,25 @@ class LLM(RetryMixin, DebugMixin):
try:
# we don't support streaming here, thus we get a ModelResponse
resp: ModelResponse = completion_unwrapped(*args, **kwargs)
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
non_fncall_response = copy.deepcopy(resp)
if mock_function_calling:
assert len(resp.choices) == 1
assert mock_fncall_tools is not None
non_fncall_response_message = resp.choices[0].message
fn_call_messages_with_response = (
convert_non_fncall_messages_to_fncall_messages(
messages + [non_fncall_response_message], mock_fncall_tools
)
)
fn_call_response_message = fn_call_messages_with_response[-1]
if not isinstance(fn_call_response_message, LiteLLMMessage):
fn_call_response_message = LiteLLMMessage(
**fn_call_response_message
)
resp.choices[0].message = fn_call_response_message
# log for evals or other scripts that need the raw completion
if self.config.log_completions:
assert self.config.log_completions_folder is not None
@@ -202,25 +245,23 @@ class LLM(RetryMixin, DebugMixin):
# use the metric model name (for draft editor)
f'{self.metrics.model_name.replace("/", "__")}-{time.time()}.json',
)
from openhands.core.utils import json
_d = {
'messages': messages,
'response': resp,
'args': args,
'kwargs': {k: v for k, v in kwargs.items() if k != 'messages'},
'timestamp': time.time(),
'cost': self._completion_cost(resp),
}
if mock_function_calling:
# Overwrite response as non-fncall to be consistent with `messages``
_d['response'] = non_fncall_response
# Save fncall_messages/response separately
_d['fncall_messages'] = original_fncall_messages
_d['fncall_response'] = resp
with open(log_file, 'w') as f:
f.write(
json.dumps(
{
'messages': messages,
'response': resp,
'args': args,
'kwargs': {
k: v
for k, v in kwargs.items()
if k != 'messages'
},
'timestamp': time.time(),
'cost': self._completion_cost(resp),
},
)
)
f.write(json.dumps(_d))
message_back: str = resp['choices'][0]['message']['content']
@@ -330,7 +371,9 @@ class LLM(RetryMixin, DebugMixin):
self.config.max_output_tokens = self.model_info['max_tokens']
def vision_is_active(self):
return not self.config.disable_vision and self._supports_vision()
with warnings.catch_warnings():
warnings.simplefilter('ignore')
return not self.config.disable_vision and self._supports_vision()
def _supports_vision(self):
"""Acquire from litellm if model is vision capable.
@@ -358,15 +401,13 @@ class LLM(RetryMixin, DebugMixin):
Returns:
boolean: True if prompt caching is supported and enabled for the given model.
"""
return self.config.caching_prompt is True and (
(
return (
self.config.caching_prompt is True
and (
self.config.model in CACHE_PROMPT_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in CACHE_PROMPT_SUPPORTED_MODELS
)
or (
self.model_info is not None
and self.model_info.get('supports_prompt_caching', False)
)
# We don't need to look-up model_info, because only Anthropic models needs the explicit caching breakpoint
)
def is_function_calling_active(self) -> bool:
@@ -376,10 +417,7 @@ class LLM(RetryMixin, DebugMixin):
or self.config.model.split('/')[-1] in FUNCTION_CALLING_SUPPORTED_MODELS
or any(m in self.config.model for m in FUNCTION_CALLING_SUPPORTED_MODELS)
)
return model_name_supported or (
self.model_info is not None
and self.model_info.get('supports_function_calling', False)
)
return model_name_supported
def _post_completion(self, response: ModelResponse) -> None:
"""Post-process the completion response.

View File

@@ -0,0 +1,182 @@
# OpenHands Github Issue Resolver 🙌
Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out!
This tool allows you to use open-source AI agents based on [OpenHands](https://github.com/all-hands-ai/openhands)
to attempt to resolve GitHub issues automatically. While it can handle multiple issues, it's primarily designed
to help you resolve one issue at a time with high quality.
Getting started is simple - just follow the instructions below.
## Using the GitHub Actions Workflow
This repository includes a GitHub Actions workflow that can automatically attempt to fix individual issues labeled with 'fix-me'.
Follow these steps to use this workflow in your own repository:
1. [Create a personal access token](https://github.com/settings/tokens?type=beta) with read/write scope for "contents", "issues", "pull requests", and "workflows"
2. Create an API key for the [Claude API](https://www.anthropic.com/api) (recommended) or another supported LLM service
3. Copy `examples/openhands-resolver.yml` to your repository's `.github/workflows/` directory
4. Configure repository permissions:
- Go to `Settings -> Actions -> General -> Workflow permissions`
- Select "Read and write permissions"
- Enable "Allow Github Actions to create and approve pull requests"
Note: If the "Read and write permissions" option is greyed out:
- First check if permissions need to be set at the organization level
- If still greyed out at the organization level, permissions need to be set in the [Enterprise policy settings](https://docs.github.com/en/enterprise-cloud@latest/admin/enforcing-policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise#enforcing-a-policy-for-workflow-permissions-in-your-enterprise)
5. Set up [GitHub secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions):
- Required:
- `PAT_USERNAME`: GitHub username for the personal access token
- `PAT_TOKEN`: The personal access token
- `LLM_MODEL`: LLM model to use (e.g., "anthropic/claude-3-5-sonnet-20241022")
- `LLM_API_KEY`: Your LLM API key
- Optional:
- `LLM_BASE_URL`: Base URL for LLM API (only if using a proxy)
Note: You can set these secrets at the organization level to use across multiple repositories.
6. Usage:
There are two ways to trigger the OpenHands agent:
a. Using the 'fix-me' label:
- Add the 'fix-me' label to any issue you want the AI to resolve
- The agent will consider all comments in the issue thread when resolving
- The workflow will:
1. Attempt to resolve the issue using OpenHands
2. Create a draft PR if successful, or push a branch if unsuccessful
3. Comment on the issue with the results
4. Remove the 'fix-me' label once processed
b. Using `@openhands-agent` mention:
- Create a new comment containing `@openhands-agent` in any issue
- The agent will only consider the comment where it's mentioned
- The workflow will:
1. Attempt to resolve the issue based on the specific comment
2. Create a draft PR if successful, or push a branch if unsuccessful
3. Comment on the issue with the results
Need help? Feel free to [open an issue](https://github.com/all-hands-ai/openhands-resolver/issues) or email us at [contact@all-hands.dev](mailto:contact@all-hands.dev).
## Manual Installation
If you prefer to run the resolver programmatically instead of using GitHub Actions, follow these steps:
1. Install the package:
```bash
pip install openhands-ai
```
2. Create a GitHub access token:
- Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new)
- Create a fine-grained token with these scopes:
- "Content"
- "Pull requests"
- "Issues"
- "Workflows"
- If you don't have push access to the target repo, you can fork it first
3. Set up environment variables:
```bash
# GitHub credentials
export GITHUB_TOKEN="your-github-token"
export GITHUB_USERNAME="your-github-username" # Optional, defaults to token owner
# LLM configuration
export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022" # Recommended
export LLM_API_KEY="your-llm-api-key"
export LLM_BASE_URL="your-api-url" # Optional, for API proxies
```
Note: OpenHands works best with powerful models like Anthropic's Claude or OpenAI's GPT-4. While other models are supported, they may not perform as well for complex issue resolution.
## Resolving Issues
The resolver can automatically attempt to fix a single issue in your repository using the following command:
```bash
python -m openhands.resolver.resolve_issue --repo [OWNER]/[REPO] --issue-number [NUMBER]
```
For instance, if you want to resolve issue #100 in this repo, you would run:
```bash
python -m openhands.resolver.resolve_issue --repo all-hands-ai/openhands-resolver --issue-number 100
```
The output will be written to the `output/` directory.
If you've installed the package from source using poetry, you can use:
```bash
poetry run python openhands/resolver/resolve_issue.py --repo all-hands-ai/openhands-resolver --issue-number 100
```
For resolving multiple issues at once (e.g., in a batch process), you can use the `resolve_all_issues` command:
```bash
python -m openhands.resolver.resolve_all_issues --repo [OWNER]/[REPO] --issue-numbers [NUMBERS]
```
For example:
```bash
python -m openhands.resolver.resolve_all_issues --repo all-hands-ai/openhands-resolver --issue-numbers 100,101,102
```
## Responding to PR Comments
The resolver can also respond to comments on pull requests using:
```bash
python -m openhands.resolver.send_pull_request --issue-number PR_NUMBER --issue-type pr
```
This functionality is available both through the GitHub Actions workflow and when running the resolver locally.
## Visualizing successful PRs
To find successful PRs, you can run the following command:
```bash
grep '"success":true' output/output.jsonl | sed 's/.*\("number":[0-9]*\).*/\1/g'
```
Then you can go through and visualize the ones you'd like.
```bash
python -m openhands.resolver.visualize_resolver_output --issue-number ISSUE_NUMBER --vis-method json
```
## Uploading PRs
If you find any PRs that were successful, you can upload them.
There are three ways you can upload:
1. `branch` - upload a branch without creating a PR
2. `draft` - create a draft PR
3. `ready` - create a non-draft PR that's ready for review
```bash
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --github-username YOUR_GITHUB_USERNAME --pr-type draft
```
If you want to upload to a fork, you can do so by specifying the `fork-owner`:
```bash
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --github-username YOUR_GITHUB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_USERNAME
```
## Providing Custom Instructions
You can customize how the AI agent approaches issue resolution by adding a `.openhands_instructions` file to the root of your repository. If present, this file's contents will be injected into the prompt for openhands edits.
## Troubleshooting
If you have any issues, please open an issue on this github repo, we're happy to help!
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the [OpenHands Slack workspace](https://join.slack.com/t/opendevin/shared_invite/zt-2oikve2hu-UDxHeo8nsE69y6T7yFX_BA) and ask there.

View File

@@ -0,0 +1,34 @@
name: Resolve Issue with OpenHands
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
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'))
}}
uses: All-Hands-AI/OpenHands/.github/workflows/openhands-resolver.yml@main
with:
macro: ${{ vars.OPENHANDS_MACRO || '@openhands-agent' }}
max_iterations: 50
secrets:
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel
class ReviewThread(BaseModel):
comment: str
files: list[str]
class GithubIssue(BaseModel):
owner: str
repo: str
number: int
title: str
body: str
thread_comments: list[str] | None = None # Added field for issue thread comments
closing_issues: list[str] | None = None
review_comments: list[str] | None = None
review_threads: list[ReviewThread] | None = None
thread_ids: list[str] | None = None
head_branch: str | None = None

View File

@@ -0,0 +1,17 @@
import json
from typing import Iterable
from openhands.resolver.resolver_output import ResolverOutput
def load_all_resolver_outputs(output_jsonl: str) -> Iterable[ResolverOutput]:
with open(output_jsonl, 'r') as f:
for line in f:
yield ResolverOutput.model_validate(json.loads(line))
def load_single_resolver_output(output_jsonl: str, issue_number: int) -> ResolverOutput:
for resolver_output in load_all_resolver_outputs(output_jsonl):
if resolver_output.issue.number == issue_number:
return resolver_output
raise ValueError(f'Issue number {issue_number} not found in {output_jsonl}')

View File

@@ -0,0 +1,745 @@
import json
import os
import re
from abc import ABC, abstractmethod
from typing import Any, ClassVar
import jinja2
import litellm
import requests
from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events.event import Event
from openhands.resolver.github_issue import GithubIssue, ReviewThread
class IssueHandlerInterface(ABC):
issue_type: ClassVar[str]
@abstractmethod
def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
"""Download issues from GitHub."""
pass
@abstractmethod
def get_instruction(
self,
issue: GithubIssue,
prompt_template: str,
repo_instruction: str | None = None,
) -> tuple[str, list[str]]:
"""Generate instruction and image urls for the agent."""
pass
@abstractmethod
def guess_success(
self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig
) -> tuple[bool, list[bool] | None, str]:
"""Guess if the issue has been resolved based on the agent's output."""
pass
class IssueHandler(IssueHandlerInterface):
issue_type: ClassVar[str] = 'issue'
def __init__(self, owner: str, repo: str, token: str):
self.download_url = 'https://api.github.com/repos/{}/{}/issues'
self.owner = owner
self.repo = repo
self.token = token
def _download_issues_from_github(self) -> list[Any]:
url = self.download_url.format(self.owner, self.repo)
headers = {
'Authorization': f'token {self.token}',
'Accept': 'application/vnd.github.v3+json',
}
params: dict[str, int | str] = {'state': 'open', 'per_page': 100, 'page': 1}
all_issues = []
while True:
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
issues = response.json()
if not issues:
break
if not isinstance(issues, list) or any(
[not isinstance(issue, dict) for issue in issues]
):
raise ValueError('Expected list of dictionaries from Github API.')
all_issues.extend(issues)
assert isinstance(params['page'], int)
params['page'] += 1
return all_issues
def _extract_image_urls(self, issue_body: str) -> list[str]:
# Regular expression to match Markdown image syntax ![alt text](image_url)
image_pattern = r'!\[.*?\]\((https?://[^\s)]+)\)'
return re.findall(image_pattern, issue_body)
def _extract_issue_references(self, body: str) -> list[int]:
# 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(
self, issue_number: int, comment_id: int | None = None
) -> list[str] | None:
"""Download comments for a specific issue from Github."""
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments'
headers = {
'Authorization': f'token {self.token}',
'Accept': 'application/vnd.github.v3+json',
}
params = {'per_page': 100, 'page': 1}
all_comments = []
while True:
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
comments = response.json()
if not comments:
break
if comment_id:
matching_comment = next(
(
comment['body']
for comment in comments
if comment['id'] == comment_id
),
None,
)
if matching_comment:
return [matching_comment]
else:
all_comments.extend([comment['body'] for comment in comments])
params['page'] += 1
return all_comments if all_comments else None
def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
"""Download issues from Github.
Returns:
List of Github issues.
"""
all_issues = self._download_issues_from_github()
converted_issues = []
for issue in all_issues:
if any([issue.get(key) is None for key in ['number', 'title', 'body']]):
logger.warning(
f'Skipping issue {issue} as it is missing number, title, or body.'
)
continue
if 'pull_request' in issue:
continue
# Get issue thread comments
thread_comments = self._get_issue_comments(
issue['number'], comment_id=comment_id
)
# Convert empty lists to None for optional fields
issue_details = GithubIssue(
owner=self.owner,
repo=self.repo,
number=issue['number'],
title=issue['title'],
body=issue['body'],
thread_comments=thread_comments,
review_comments=None, # Initialize review comments as None for regular issues
)
converted_issues.append(issue_details)
return converted_issues
def get_instruction(
self,
issue: GithubIssue,
prompt_template: str,
repo_instruction: str | None = None,
) -> tuple[str, list[str]]:
"""Generate instruction for the agent."""
# Format thread comments if they exist
thread_context = ''
if issue.thread_comments:
thread_context = '\n\nIssue Thread Comments:\n' + '\n---\n'.join(
issue.thread_comments
)
images = []
images.extend(self._extract_image_urls(issue.body))
images.extend(self._extract_image_urls(thread_context))
template = jinja2.Template(prompt_template)
return (
template.render(
body=issue.title + '\n\n' + issue.body + thread_context,
repo_instruction=repo_instruction,
),
images,
)
def guess_success(
self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig
) -> tuple[bool, None | list[bool], str]:
"""Guess if the issue is fixed based on the history and the issue description."""
last_message = history[-1].message
# Include thread comments in the prompt if they exist
issue_context = issue.body
if issue.thread_comments:
issue_context += '\n\nIssue Thread Comments:\n' + '\n---\n'.join(
issue.thread_comments
)
with open(
os.path.join(
os.path.dirname(__file__),
'prompts/guess_success/issue-success-check.jinja',
),
'r',
) as f:
template = jinja2.Template(f.read())
prompt = template.render(issue_context=issue_context, last_message=last_message)
response = litellm.completion(
model=llm_config.model,
messages=[{'role': 'user', 'content': prompt}],
api_key=llm_config.api_key,
base_url=llm_config.base_url,
)
answer = response.choices[0].message.content.strip()
pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)'
match = re.search(pattern, answer)
if match:
return match.group(1).lower() == 'true', None, match.group(2)
return False, None, f'Failed to decode answer from LLM response: {answer}'
class PRHandler(IssueHandler):
issue_type: ClassVar[str] = 'pr'
def __init__(self, owner: str, repo: str, token: str):
super().__init__(owner, repo, token)
self.download_url = 'https://api.github.com/repos/{}/{}/pulls'
def __download_pr_metadata(
self, pull_number: int, comment_id: int | None = None
) -> tuple[list[str], list[int], list[str], list[ReviewThread], list[str]]:
"""Run a GraphQL query against the GitHub API for information.
Retrieves information about:
1. unresolved review comments
2. referenced issues the pull request would close
Args:
pull_number: The number of the pull request to query.
comment_id: Optional ID of a specific comment to focus on.
query: The GraphQL query as a string.
variables: A dictionary of variables for the query.
token: Your GitHub personal access token.
Returns:
The JSON response from the GitHub API.
"""
# Using graphql as REST API doesn't indicate resolved status for review comments
# TODO: grabbing the first 10 issues, 100 review threads, and 100 coments; add pagination to retrieve all
query = """
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
closingIssuesReferences(first: 10) {
edges {
node {
body
number
}
}
}
url
reviews(first: 100) {
nodes {
body
state
fullDatabaseId
}
}
reviewThreads(first: 100) {
edges{
node{
id
isResolved
comments(first: 100) {
totalCount
nodes {
body
path
fullDatabaseId
}
}
}
}
}
}
}
}
"""
variables = {'owner': self.owner, 'repo': self.repo, 'pr': pull_number}
url = 'https://api.github.com/graphql'
headers = {
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json',
}
response = requests.post(
url, json={'query': query, 'variables': variables}, headers=headers
)
response.raise_for_status()
response_json = response.json()
# Parse the response to get closing issue references and unresolved review comments
pr_data = (
response_json.get('data', {}).get('repository', {}).get('pullRequest', {})
)
# Get closing issues
closing_issues = pr_data.get('closingIssuesReferences', {}).get('edges', [])
closing_issues_bodies = [issue['node']['body'] for issue in closing_issues]
closing_issue_numbers = [
issue['node']['number'] for issue in closing_issues
] # Extract issue numbers
# Get review comments
reviews = pr_data.get('reviews', {}).get('nodes', [])
if comment_id is not None:
reviews = [
review
for review in reviews
if int(review['fullDatabaseId']) == comment_id
]
review_bodies = [review['body'] for review in reviews]
# Get unresolved review threads
review_threads = []
thread_ids = [] # Store thread IDs; agent replies to the thread
raw_review_threads = pr_data.get('reviewThreads', {}).get('edges', [])
for thread in raw_review_threads:
node = thread.get('node', {})
if not node.get(
'isResolved', True
): # Check if the review thread is unresolved
id = node.get('id')
thread_contains_comment_id = False
my_review_threads = node.get('comments', {}).get('nodes', [])
message = ''
files = []
for i, review_thread in enumerate(my_review_threads):
if (
comment_id is not None
and int(review_thread['fullDatabaseId']) == comment_id
):
thread_contains_comment_id = True
if (
i == len(my_review_threads) - 1
): # Check if it's the last thread in the thread
if len(my_review_threads) > 1:
message += '---\n' # Add "---" before the last message if there's more than one thread
message += 'latest feedback:\n' + review_thread['body'] + '\n'
else:
message += (
review_thread['body'] + '\n'
) # Add each thread in a new line
file = review_thread.get('path')
if file and file not in files:
files.append(file)
if comment_id is None or thread_contains_comment_id:
unresolved_thread = ReviewThread(comment=message, files=files)
review_threads.append(unresolved_thread)
thread_ids.append(id)
return (
closing_issues_bodies,
closing_issue_numbers,
review_bodies,
review_threads,
thread_ids,
)
# Override processing of downloaded issues
def _get_pr_comments(
self, pr_number: int, comment_id: int | None = None
) -> list[str] | None:
"""Download comments for a specific pull request from Github."""
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments'
headers = {
'Authorization': f'token {self.token}',
'Accept': 'application/vnd.github.v3+json',
}
params = {'per_page': 100, 'page': 1}
all_comments = []
while True:
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
comments = response.json()
if not comments:
break
if comment_id is not None:
matching_comment = next(
(
comment['body']
for comment in comments
if comment['id'] == comment_id
),
None,
)
if matching_comment:
return [matching_comment]
else:
all_comments.extend([comment['body'] for comment in comments])
params['page'] += 1
return all_comments if all_comments else None
def __get_context_from_external_issues_references(
self,
closing_issues: list[str],
closing_issue_numbers: list[int],
issue_body: str,
review_comments: list[str],
review_threads: list[ReviewThread],
thread_comments: list[str] | None,
):
new_issue_references = []
if issue_body:
new_issue_references.extend(self._extract_issue_references(issue_body))
if review_comments:
for comment in review_comments:
new_issue_references.extend(self._extract_issue_references(comment))
if review_threads:
for review_thread in review_threads:
new_issue_references.extend(
self._extract_issue_references(review_thread.comment)
)
if thread_comments:
for thread_comment in thread_comments:
new_issue_references.extend(
self._extract_issue_references(thread_comment)
)
non_duplicate_references = set(new_issue_references)
unique_issue_references = non_duplicate_references.difference(
closing_issue_numbers
)
for issue_number in unique_issue_references:
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]:
all_issues = self._download_issues_from_github()
converted_issues = []
for issue in all_issues:
# For PRs, body can be None
if any([issue.get(key) is None for key in ['number', 'title']]):
logger.warning(f'Skipping #{issue} as it is missing number or title.')
continue
# Handle None body for PRs
body = issue.get('body') if issue.get('body') is not None else ''
(
closing_issues,
closing_issues_numbers,
review_comments,
review_threads,
thread_ids,
) = self.__download_pr_metadata(issue['number'], comment_id=comment_id)
head_branch = issue['head']['ref']
# Get PR thread comments
thread_comments = self._get_pr_comments(
issue['number'], comment_id=comment_id
)
closing_issues = self.__get_context_from_external_issues_references(
closing_issues,
closing_issues_numbers,
body,
review_comments,
review_threads,
thread_comments,
)
issue_details = GithubIssue(
owner=self.owner,
repo=self.repo,
number=issue['number'],
title=issue['title'],
body=body,
closing_issues=closing_issues,
review_comments=review_comments,
review_threads=review_threads,
thread_ids=thread_ids,
head_branch=head_branch,
thread_comments=thread_comments,
)
converted_issues.append(issue_details)
return converted_issues
def get_instruction(
self,
issue: GithubIssue,
prompt_template: str,
repo_instruction: str | None = None,
) -> tuple[str, list[str]]:
"""Generate instruction for the agent."""
template = jinja2.Template(prompt_template)
images = []
issues_str = None
if issue.closing_issues:
issues_str = json.dumps(issue.closing_issues, indent=4)
images.extend(self._extract_image_urls(issues_str))
# Handle PRs with review comments
review_comments_str = None
if issue.review_comments:
review_comments_str = json.dumps(issue.review_comments, indent=4)
images.extend(self._extract_image_urls(review_comments_str))
# Handle PRs with file-specific review comments
review_thread_str = None
review_thread_file_str = None
if issue.review_threads:
review_threads = [
review_thread.comment for review_thread in issue.review_threads
]
review_thread_files = []
for review_thread in issue.review_threads:
review_thread_files.extend(review_thread.files)
review_thread_str = json.dumps(review_threads, indent=4)
review_thread_file_str = json.dumps(review_thread_files, indent=4)
images.extend(self._extract_image_urls(review_thread_str))
# 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
)
images.extend(self._extract_image_urls(thread_context))
instruction = template.render(
issues=issues_str,
review_comments=review_comments_str,
review_threads=review_thread_str,
files=review_thread_file_str,
thread_context=thread_context,
repo_instruction=repo_instruction,
)
return instruction, images
def _check_feedback_with_llm(
self, prompt: str, llm_config: LLMConfig
) -> tuple[bool, str]:
"""Helper function to check feedback with LLM and parse response."""
response = litellm.completion(
model=llm_config.model,
messages=[{'role': 'user', 'content': prompt}],
api_key=llm_config.api_key,
base_url=llm_config.base_url,
)
answer = response.choices[0].message.content.strip()
pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)'
match = re.search(pattern, answer)
if match:
return match.group(1).lower() == 'true', match.group(2).strip()
return False, f'Failed to decode answer from LLM response: {answer}'
def _check_review_thread(
self,
review_thread: ReviewThread,
issues_context: str,
last_message: str,
llm_config: LLMConfig,
) -> tuple[bool, str]:
"""Check if a review thread's feedback has been addressed."""
files_context = json.dumps(review_thread.files, indent=4)
with open(
os.path.join(
os.path.dirname(__file__),
'prompts/guess_success/pr-feedback-check.jinja',
),
'r',
) as f:
template = jinja2.Template(f.read())
prompt = template.render(
issue_context=issues_context,
feedback=review_thread.comment,
files_context=files_context,
last_message=last_message,
)
return self._check_feedback_with_llm(prompt, llm_config)
def _check_thread_comments(
self,
thread_comments: list[str],
issues_context: str,
last_message: str,
llm_config: LLMConfig,
) -> tuple[bool, str]:
"""Check if thread comments feedback has been addressed."""
thread_context = '\n---\n'.join(thread_comments)
with open(
os.path.join(
os.path.dirname(__file__), 'prompts/guess_success/pr-thread-check.jinja'
),
'r',
) as f:
template = jinja2.Template(f.read())
prompt = template.render(
issue_context=issues_context,
thread_context=thread_context,
last_message=last_message,
)
return self._check_feedback_with_llm(prompt, llm_config)
def _check_review_comments(
self,
review_comments: list[str],
issues_context: str,
last_message: str,
llm_config: LLMConfig,
) -> tuple[bool, str]:
"""Check if review comments feedback has been addressed."""
review_context = '\n---\n'.join(review_comments)
with open(
os.path.join(
os.path.dirname(__file__), 'prompts/guess_success/pr-review-check.jinja'
),
'r',
) as f:
template = jinja2.Template(f.read())
prompt = template.render(
issue_context=issues_context,
review_context=review_context,
last_message=last_message,
)
return self._check_feedback_with_llm(prompt, llm_config)
def guess_success(
self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig
) -> tuple[bool, None | list[bool], str]:
"""Guess if the issue is fixed based on the history and the issue description."""
last_message = history[-1].message
issues_context = json.dumps(issue.closing_issues, indent=4)
success_list = []
explanation_list = []
# Handle PRs with file-specific review comments
if issue.review_threads:
for review_thread in issue.review_threads:
if issues_context and last_message:
success, explanation = self._check_review_thread(
review_thread, issues_context, last_message, llm_config
)
else:
success, explanation = False, 'Missing context or message'
success_list.append(success)
explanation_list.append(explanation)
# Handle PRs with only thread comments (no file-specific review comments)
elif issue.thread_comments:
if issue.thread_comments and issues_context and last_message:
success, explanation = self._check_thread_comments(
issue.thread_comments, issues_context, last_message, llm_config
)
else:
success, explanation = (
False,
'Missing thread comments, context or message',
)
success_list.append(success)
explanation_list.append(explanation)
elif issue.review_comments:
# Handle PRs with only review comments (no file-specific review comments or thread comments)
if issue.review_comments and issues_context and last_message:
success, explanation = self._check_review_comments(
issue.review_comments, issues_context, last_message, llm_config
)
else:
success, explanation = (
False,
'Missing review comments, context or message',
)
success_list.append(success)
explanation_list.append(explanation)
else:
# No review comments, thread comments, or file-level review comments found
return False, None, 'No feedback was found to process'
# Return overall success (all must be true) and explanations
if not success_list:
return False, None, 'No feedback was processed'
return all(success_list), success_list, '\n'.join(explanation_list)

View File

@@ -0,0 +1,4 @@
# Patching code
Originally from [whatthepatch](https://github.com/cscorley/whatthepatch)
(MIT license)

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from .apply import apply_diff
from .patch import parse_patch
__all__ = ['parse_patch', 'apply_diff']

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
import os.path
import subprocess
import tempfile
from .exceptions import HunkApplyException, SubprocessException
from .snippets import remove, which
def _apply_diff_with_subprocess(diff, lines, reverse=False):
# call out to patch program
patchexec = which('patch')
if not patchexec:
raise SubprocessException('cannot find patch program', code=-1)
tempdir = tempfile.gettempdir()
filepath = os.path.join(tempdir, 'wtp-' + str(hash(diff.header)))
oldfilepath = filepath + '.old'
newfilepath = filepath + '.new'
rejfilepath = filepath + '.rej'
patchfilepath = filepath + '.patch'
with open(oldfilepath, 'w') as f:
f.write('\n'.join(lines) + '\n')
with open(patchfilepath, 'w') as f:
f.write(diff.text)
args = [
patchexec,
'--reverse' if reverse else '--forward',
'--quiet',
'--no-backup-if-mismatch',
'-o',
newfilepath,
'-i',
patchfilepath,
'-r',
rejfilepath,
oldfilepath,
]
ret = subprocess.call(args)
with open(newfilepath) as f:
lines = f.read().splitlines()
try:
with open(rejfilepath) as f:
rejlines = f.read().splitlines()
except IOError:
rejlines = None
remove(oldfilepath)
remove(newfilepath)
remove(rejfilepath)
remove(patchfilepath)
# do this last to ensure files get cleaned up
if ret != 0:
raise SubprocessException('patch program failed', code=ret)
return lines, rejlines
def _reverse(changes):
def _reverse_change(c):
return c._replace(old=c.new, new=c.old)
return [_reverse_change(c) for c in changes]
def apply_diff(diff, text, reverse=False, use_patch=False):
try:
lines = text.splitlines()
except AttributeError:
lines = list(text)
if use_patch:
return _apply_diff_with_subprocess(diff, lines, reverse)
n_lines = len(lines)
changes = _reverse(diff.changes) if reverse else diff.changes
# check that the source text matches the context of the diff
for old, new, line, hunk in changes:
# might have to check for line is None here for ed scripts
if old is not None and line is not None:
if old > n_lines:
raise HunkApplyException(
'context line {n}, "{line}" does not exist in source'.format(
n=old, line=line
),
hunk=hunk,
)
if lines[old - 1] != line:
raise HunkApplyException(
'context line {n}, "{line}" does not match "{sl}"'.format(
n=old, line=line, sl=lines[old - 1]
),
hunk=hunk,
)
# for calculating the old line
r = 0
i = 0
for old, new, line, hunk in changes:
if old is not None and new is None:
del lines[old - 1 - r + i]
r += 1
elif old is None and new is not None:
lines.insert(new - 1, line)
i += 1
elif old is not None and new is not None:
# Sometimes, people remove hunks from patches, making these
# numbers completely unreliable. Because they're jerks.
pass
return lines

Some files were not shown because too many files have changed in this diff Show More