mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
45 Commits
0.32.0
...
xw/evaluat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ca0b6a9b | ||
|
|
e0fcd7a61e | ||
|
|
e9989d1085 | ||
|
|
49c515b252 | ||
|
|
2d05578c21 | ||
|
|
d05a6f30e1 | ||
|
|
10c81c39fb | ||
|
|
2d599349ef | ||
|
|
33caf5c6ca | ||
|
|
a9850766a7 | ||
|
|
77e2416def | ||
|
|
02af9865ec | ||
|
|
75ca2aa6b1 | ||
|
|
d820661592 | ||
|
|
1ff351a4f1 | ||
|
|
78b8e58561 | ||
|
|
fddbfce51a | ||
|
|
20d3766451 | ||
|
|
72b5e18898 | ||
|
|
03b8b8c19a | ||
|
|
7c2f1b075e | ||
|
|
883da1b28c | ||
|
|
bb98d94b35 | ||
|
|
d114c45135 | ||
|
|
36e092e0ac | ||
|
|
e2bb69908a | ||
|
|
cd33c5eac7 | ||
|
|
0f8a139fb5 | ||
|
|
ced4ee3038 | ||
|
|
cc19dac939 | ||
|
|
4c7c73a6f2 | ||
|
|
0493fea9fc | ||
|
|
1c2db9f468 | ||
|
|
c210230802 | ||
|
|
9b12989a36 | ||
|
|
f1bcd72cd8 | ||
|
|
a5c57fbd3a | ||
|
|
513f7ab7e7 | ||
|
|
53c0c5a07b | ||
|
|
d924e7cea5 | ||
|
|
7910a90522 | ||
|
|
35d49f6941 | ||
|
|
e359a4affa | ||
|
|
159f79f9d8 | ||
|
|
827c19ccd9 |
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -12,3 +12,6 @@ assignees: ''
|
||||
**Describe the UX or technical implementation you have in mind**
|
||||
|
||||
**Additional context**
|
||||
|
||||
|
||||
### If you find this feature request or enhancement useful, make sure to add a 👍 to the issue
|
||||
|
||||
71
.github/workflows/ghcr-build.yml
vendored
71
.github/workflows/ghcr-build.yml
vendored
@@ -145,7 +145,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Create source distribution and Dockerfile
|
||||
run: poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
|
||||
- name: Lowercase Repository Owner
|
||||
@@ -174,20 +174,19 @@ jobs:
|
||||
build-args: ${{ env.DOCKER_BUILD_ARGS }}
|
||||
context: containers/runtime
|
||||
provenance: false
|
||||
# Forked repos can't push to GHCR, so we need to upload the image as an artifact
|
||||
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
outputs: type=docker,dest=/tmp/runtime-${{ matrix.base_image.tag }}.tar
|
||||
context: containers/runtime
|
||||
- name: Upload runtime image for fork
|
||||
- name: Upload runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-${{ matrix.base_image.tag }}
|
||||
path: /tmp/runtime-${{ matrix.base_image.tag }}.tar
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
|
||||
verify_hash_equivalence_in_runtime_and_app:
|
||||
name: Verify Hash Equivalence in Runtime and Docker images
|
||||
@@ -220,7 +219,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Get hash in App Image
|
||||
run: |
|
||||
echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
|
||||
@@ -248,7 +247,7 @@ jobs:
|
||||
test_runtime_root:
|
||||
name: RT Unit Tests (Root)
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -258,17 +257,23 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# Forked repos can't push to GHCR, so we need to download the image as an artifact
|
||||
- name: Download runtime image for fork
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: runtime-${{ matrix.base_image.tag }}
|
||||
path: /tmp
|
||||
- name: Load runtime image for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
docker load --input /tmp/runtime-${{ matrix.base_image.tag }}.tar
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
# Forked repos can't push to GHCR, so we need to rebuild using cache
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Cache Poetry dependencies
|
||||
uses: useblacksmith/cache@v5
|
||||
with:
|
||||
@@ -286,10 +291,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
|
||||
- name: Run docker runtime tests
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
@@ -308,7 +310,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 3 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
env:
|
||||
@@ -317,7 +319,7 @@ jobs:
|
||||
# Run unit tests with the Docker runtime Docker images as openhands user
|
||||
test_runtime_oh:
|
||||
name: RT Unit Tests (openhands)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -327,17 +329,23 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# Forked repos can't push to GHCR, so we need to download the image as an artifact
|
||||
- name: Download runtime image for fork
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: runtime-${{ matrix.base_image.tag }}
|
||||
path: /tmp
|
||||
- name: Load runtime image for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
docker load --input /tmp/runtime-${{ matrix.base_image.tag }}.tar
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
# Forked repos can't push to GHCR, so we need to rebuild using cache
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Cache Poetry dependencies
|
||||
uses: useblacksmith/cache@v5
|
||||
with:
|
||||
@@ -355,10 +363,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
|
||||
- name: Run runtime tests
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
@@ -374,7 +379,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 3 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
env:
|
||||
|
||||
9
.github/workflows/openhands-resolver.yml
vendored
9
.github/workflows/openhands-resolver.yml
vendored
@@ -16,6 +16,11 @@ on:
|
||||
type: string
|
||||
default: "main"
|
||||
description: "Target branch to pull and create PR against"
|
||||
pr_type:
|
||||
required: false
|
||||
type: string
|
||||
default: "draft"
|
||||
description: "The PR type that is going to be created (draft, ready)"
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
type: string
|
||||
@@ -280,9 +285,9 @@ jobs:
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--target-branch ${{ env.TARGET_BRANCH }} \
|
||||
--pr-type draft \
|
||||
--pr-type ${{ inputs.pr_type || 'draft' }} \
|
||||
--reviewer ${{ github.actor }} | tee pr_result.txt && \
|
||||
grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
|
||||
grep "PR 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 }} \
|
||||
|
||||
31
Makefile
31
Makefile
@@ -133,20 +133,29 @@ install-python-dependencies:
|
||||
export HNSWLIB_NO_NATIVE=1; \
|
||||
poetry run pip install chroma-hnswlib; \
|
||||
fi
|
||||
@poetry install
|
||||
@if [ -f "/etc/manjaro-release" ]; then \
|
||||
echo "$(BLUE)Detected Manjaro Linux. Installing Playwright dependencies...$(RESET)"; \
|
||||
poetry run pip install playwright; \
|
||||
poetry run playwright install chromium; \
|
||||
@if [ -n "${POETRY_GROUP}" ]; then \
|
||||
echo "Installing only POETRY_GROUP=${POETRY_GROUP}"; \
|
||||
poetry install --only $${POETRY_GROUP}; \
|
||||
else \
|
||||
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
|
||||
echo "Running playwright install --with-deps chromium..."; \
|
||||
poetry run playwright install --with-deps chromium; \
|
||||
mkdir -p cache; \
|
||||
touch cache/playwright_chromium_is_installed.txt; \
|
||||
poetry install; \
|
||||
fi
|
||||
@if [ "${INSTALL_PLAYWRIGHT}" != "false" ] && [ "${INSTALL_PLAYWRIGHT}" != "0" ]; then \
|
||||
if [ -f "/etc/manjaro-release" ]; then \
|
||||
echo "$(BLUE)Detected Manjaro Linux. Installing Playwright dependencies...$(RESET)"; \
|
||||
poetry run pip install playwright; \
|
||||
poetry run playwright install chromium; \
|
||||
else \
|
||||
echo "Setup already done. Skipping playwright installation."; \
|
||||
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
|
||||
echo "Running playwright install --with-deps chromium..."; \
|
||||
poetry run playwright install --with-deps chromium; \
|
||||
mkdir -p cache; \
|
||||
touch cache/playwright_chromium_is_installed.txt; \
|
||||
else \
|
||||
echo "Setup already done. Skipping playwright installation."; \
|
||||
fi \
|
||||
fi \
|
||||
else \
|
||||
echo "Skipping Playwright installation (INSTALL_PLAYWRIGHT=${INSTALL_PLAYWRIGHT})."; \
|
||||
fi
|
||||
@echo "$(GREEN)Python dependencies installed successfully.$(RESET)"
|
||||
|
||||
|
||||
39
README.md
39
README.md
@@ -27,7 +27,7 @@ Welcome to OpenHands (formerly OpenDevin), a platform for software development a
|
||||
OpenHands agents can do anything a human developer can: modify code, run commands, browse the web,
|
||||
call APIs, and yes—even copy code snippets from StackOverflow.
|
||||
|
||||
Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or jump to the [Quick Start](#-quick-start).
|
||||
Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for OpenHands Cloud](https://app.all-hands.dev) to get started.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Using OpenHands for work? We'd love to chat! Fill out
|
||||
@@ -36,12 +36,21 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or jump to the [
|
||||
|
||||

|
||||
|
||||
## ⚡ Quick Start
|
||||
## ☁️ OpenHands Cloud
|
||||
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
|
||||
which comes with $50 in free credits for new users.
|
||||
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
## 💻 Running OpenHands Locally
|
||||
|
||||
OpenHands can also run on your local system using Docker.
|
||||
See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installation) guide for
|
||||
system requirements and more information.
|
||||
|
||||
> [!WARNING]
|
||||
> On a public network? See our [Hardened Docker Installation Guide](https://docs.all-hands.dev/modules/usage/runtimes/docker#hardened-docker-installation)
|
||||
> to secure your deployment by restricting network binding and implementing additional security measures.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik
|
||||
|
||||
@@ -56,17 +65,21 @@ docker run -it --rm --pull=always \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.32
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> On a public network? See our [Hardened Docker Installation](https://docs.all-hands.dev/modules/usage/runtimes/docker#hardened-docker-installation) guide
|
||||
> to secure your deployment by restricting network binding and implementing additional security measures.
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
Finally, you'll need a model provider and API key.
|
||||
When you open the application, you'll be asked to choose an LLM provider and add an API key.
|
||||
[Anthropic's Claude 3.5 Sonnet](https://www.anthropic.com/api) (`anthropic/claude-3-5-sonnet-20241022`)
|
||||
works best, but you have [many options](https://docs.all-hands.dev/modules/usage/llms).
|
||||
|
||||
---
|
||||
## 💡 Other ways to run OpenHands
|
||||
|
||||
> [!CAUTION]
|
||||
> OpenHands is meant to be run by a single user on their local workstation.
|
||||
> It is not appropriate for multi-tenant deployments where multiple users share the same instance. There is no built-in authentication, isolation, or scalability.
|
||||
>
|
||||
> If you're interested in running OpenHands in a multi-tenant environment, please
|
||||
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
> for advanced deployment options.
|
||||
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes/docker#connecting-to-your-filesystem),
|
||||
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
|
||||
@@ -75,14 +88,6 @@ or run it on tagged issues with [a github action](https://docs.all-hands.dev/mod
|
||||
|
||||
Visit [Running OpenHands](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
> [!CAUTION]
|
||||
> OpenHands is meant to be run by a single user on their local workstation.
|
||||
> It is not appropriate for multi-tenant deployments where multiple users share the same instance. There is no built-in isolation or scalability.
|
||||
>
|
||||
> If you're interested in running OpenHands in a multi-tenant environment, please
|
||||
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
> for advanced deployment options.
|
||||
|
||||
If you want to modify the OpenHands source code, check out [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
|
||||
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting) can help.
|
||||
|
||||
@@ -216,13 +216,13 @@ model = "gpt-4o"
|
||||
[agent]
|
||||
|
||||
# Whether the browsing tool is enabled
|
||||
codeact_enable_browsing = true
|
||||
enable_browsing = true
|
||||
|
||||
# Whether the LLM draft editor is enabled
|
||||
codeact_enable_llm_editor = false
|
||||
enable_llm_editor = false
|
||||
|
||||
# Whether the IPython tool is enabled
|
||||
codeact_enable_jupyter = true
|
||||
enable_jupyter = true
|
||||
|
||||
# LLM config group to use
|
||||
#llm_config = 'your-llm-config-group'
|
||||
|
||||
@@ -354,12 +354,12 @@ Les options de configuration de l'agent sont définies dans les sections `[agent
|
||||
- Valeur par défaut : `true`
|
||||
- Description : Si l'appel de fonction est activé
|
||||
|
||||
- `codeact_enable_browsing`
|
||||
- `enable_browsing`
|
||||
- Type : `bool`
|
||||
- Valeur par défaut : `false`
|
||||
- Description : Si le délégué de navigation est activé dans l'espace d'action (fonctionne uniquement avec l'appel de fonction)
|
||||
|
||||
- `codeact_enable_llm_editor`
|
||||
- `enable_llm_editor`
|
||||
- Type : `bool`
|
||||
- Valeur par défaut : `false`
|
||||
- Description : Si l'éditeur LLM est activé dans l'espace d'action (fonctionne uniquement avec l'appel de fonction)
|
||||
|
||||
@@ -348,12 +348,12 @@ dockerコマンドで使用する場合は、`-e LLM_<option>`として渡しま
|
||||
- デフォルト値: `true`
|
||||
- 説明: 関数呼び出しが有効かどうか
|
||||
|
||||
- `codeact_enable_browsing`
|
||||
- `enable_browsing`
|
||||
- 型: `bool`
|
||||
- デフォルト値: `false`
|
||||
- 説明: アクションスペースでブラウジングデリゲートが有効かどうか(関数呼び出しでのみ機能)
|
||||
|
||||
- `codeact_enable_llm_editor`
|
||||
- `enable_llm_editor`
|
||||
- 型: `bool`
|
||||
- デフォルト値: `false`
|
||||
- 説明: アクションスペースでLLMエディタが有効かどうか(関数呼び出しでのみ機能)
|
||||
|
||||
@@ -21,8 +21,8 @@ OpenHandsがリポジトリで動作する際:
|
||||
```
|
||||
---
|
||||
name: <Microagentの名前>
|
||||
type: <MicroAgentのタイプ>
|
||||
version: <MicroAgentのバージョン>
|
||||
type: <Microagentのタイプ>
|
||||
version: <Microagentのバージョン>
|
||||
agent: <エージェントのタイプ (通常はCodeActAgent)>
|
||||
triggers:
|
||||
- <オプション: microagentをトリガーするキーワード。トリガーを削除すると、常に含まれるようになります>
|
||||
|
||||
@@ -7,7 +7,7 @@ O GitHub Cloud Resolver automatiza correções de código e fornece assistência
|
||||
## Configuração
|
||||
|
||||
O Resolvedor do GitHub na Nuvem está disponível automaticamente quando você
|
||||
[concede acesso ao repositório do OpenHands Cloud](./openhands-cloud.md#adding-repository-access).
|
||||
[concede acesso ao repositório do OpenHands Cloud](./openhands-cloud#adding-repository-access).
|
||||
|
||||
## Uso
|
||||
|
||||
|
||||
@@ -292,17 +292,17 @@ As opções de configuração do agente são definidas nas seções `[agent]` e
|
||||
- Padrão: `true`
|
||||
- Descrição: Se a chamada de função está habilitada
|
||||
|
||||
- `codeact_enable_browsing`
|
||||
- `enable_browsing`
|
||||
- Tipo: `bool`
|
||||
- Padrão: `false`
|
||||
- Descrição: Se o delegado de navegação está habilitado no espaço de ação (funciona apenas com chamada de função)
|
||||
|
||||
- `codeact_enable_llm_editor`
|
||||
- `enable_llm_editor`
|
||||
- Tipo: `bool`
|
||||
- Padrão: `false`
|
||||
- Descrição: Se o editor LLM está habilitado no espaço de ação (funciona apenas com chamada de função)
|
||||
|
||||
- `codeact_enable_jupyter`
|
||||
- `enable_jupyter`
|
||||
- Tipo: `bool`
|
||||
- Padrão: `false`
|
||||
- Descrição: Se o Jupyter está habilitado no espaço de ação
|
||||
|
||||
@@ -21,8 +21,8 @@ Todos os microagentes usam arquivos markdown com frontmatter YAML que possuem in
|
||||
```
|
||||
---
|
||||
name: <Nome do microagente>
|
||||
type: <Tipo do MicroAgent>
|
||||
version: <Versão do MicroAgent>
|
||||
type: <Tipo do Microagent>
|
||||
version: <Versão do Microagent>
|
||||
agent: <O tipo de agente (normalmente CodeActAgent)>
|
||||
triggers:
|
||||
- <Palavras-chave opcionais que acionam o microagente. Se os gatilhos forem removidos, ele sempre será incluído>
|
||||
|
||||
@@ -349,17 +349,17 @@ Agent 配置选项在 `config.toml` 文件的 `[agent]` 和 `[agent.<agent_name>
|
||||
- 默认值: `true`
|
||||
- 描述: 是否启用函数调用
|
||||
|
||||
- `codeact_enable_browsing`
|
||||
- `enable_browsing`
|
||||
- 类型: `bool`
|
||||
- 默认值: `false`
|
||||
- 描述: 是否在 action space 中启用浏览代理(仅适用于函数调用)
|
||||
|
||||
- `codeact_enable_llm_editor`
|
||||
- `enable_llm_editor`
|
||||
- 类型: `bool`
|
||||
- 默认值: `false`
|
||||
- 描述: 是否在 action space 中启用 LLM 编辑器(仅适用于函数调用)
|
||||
|
||||
- `codeact_enable_jupyter`
|
||||
- `enable_jupyter`
|
||||
- 类型: `bool`
|
||||
- 默认值: `false`
|
||||
- 描述: 是否在 action space 中启用 Jupyter
|
||||
|
||||
@@ -5,7 +5,7 @@ The GitHub Resolver automates code fixes and provides intelligent assistance for
|
||||
## Setup
|
||||
|
||||
The Cloud GitHub Resolver is available automatically when you
|
||||
[grant OpenHands Cloud repository access](./openhands-cloud.md#adding-repository-access).
|
||||
[grant OpenHands Cloud repository access](./openhands-cloud#adding-repository-access).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ After visiting OpenHands Cloud, you will be asked to connect with your GitHub ac
|
||||
You can grant OpenHands specific repository access:
|
||||
1. Click the `Select a GitHub project` dropdown, select `Add more repositories...`.
|
||||
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
|
||||
- Openhands requests short-lived tokens (8-hour expiry) with these permissions:
|
||||
<details>
|
||||
<summary>Permission Details for Repository Access</summary>
|
||||
|
||||
Openhands requests short-lived tokens (8-hour expiry) with these permissions:
|
||||
- Actions: Read and write
|
||||
- Administration: Read-only
|
||||
- Commit statuses: Read and write
|
||||
@@ -31,9 +34,12 @@ You can grant OpenHands specific repository access:
|
||||
- Pull requests: Read and write
|
||||
- Webhooks: Read and write
|
||||
- Workflows: Read and write
|
||||
- Repository access for a user is granted based on:
|
||||
|
||||
Repository access for a user is granted based on:
|
||||
- Granted permission for the repository.
|
||||
- User's GitHub permissions (owner/collaborator).
|
||||
</details>
|
||||
|
||||
3. Click on `Install & Authorize`.
|
||||
|
||||
### Modifying Repository Access
|
||||
@@ -41,3 +47,9 @@ You can grant OpenHands specific repository access:
|
||||
You can modify repository access at any time by:
|
||||
* Using the same `Select a GitHub project > Add more repositories` workflow, or
|
||||
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `GitHub Settings` section.
|
||||
|
||||
## Conversation Persistence
|
||||
|
||||
- Conversations List – Displays only the 10 most recent conversations initiated within the past 10 days.
|
||||
- Workspaces – Conversation workspaces are retained for 14 days.
|
||||
- Runtimes – Runtimes remain active ("warm") for 30 minutes. After this period, resuming a conversation may take 1–2 minutes.
|
||||
@@ -291,17 +291,17 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Default: `true`
|
||||
- Description: Whether function calling is enabled
|
||||
|
||||
- `codeact_enable_browsing`
|
||||
- `enable_browsing`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Whether browsing delegate is enabled in the action space (only works with function calling)
|
||||
|
||||
- `codeact_enable_llm_editor`
|
||||
- `enable_llm_editor`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Whether LLM editor is enabled in the action space (only works with function calling)
|
||||
|
||||
- `codeact_enable_jupyter`
|
||||
- `enable_jupyter`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Whether Jupyter is enabled in the action space
|
||||
|
||||
@@ -50,3 +50,6 @@ docker run -it \
|
||||
```
|
||||
|
||||
This command will start an interactive session in Docker where you can input tasks and receive responses from OpenHands.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
@@ -47,6 +47,9 @@ docker run -it \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
## Advanced Headless Configurations
|
||||
|
||||
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
|
||||
|
||||
@@ -35,8 +35,8 @@ A useful feature is the ability to connect to your local filesystem. To mount yo
|
||||
Be careful! There's nothing stopping the OpenHands agent from deleting or modifying
|
||||
any files that are mounted into its workspace.
|
||||
|
||||
This setup can cause some issues with file permissions (hence the `SANDBOX_USER_ID` variable)
|
||||
but seems to work well on most systems.
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
## Hardened Docker Installation
|
||||
|
||||
|
||||
@@ -20,3 +20,18 @@ Try these in order:
|
||||
* If using Docker Desktop, ensure `Settings > Advanced > Allow the default Docker socket to be used` is enabled.
|
||||
* Depending on your configuration you may need `Settings > Resources > Network > Enable host networking` enabled in Docker Desktop.
|
||||
* Reinstall Docker Desktop.
|
||||
|
||||
### Permission Error
|
||||
|
||||
**Description**
|
||||
|
||||
On initial prompt, an error is seen with `Permission Denied` or `PermissionError`.
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Check if the `~/.openhands-state` is owned by `root`. If so, you can:
|
||||
* Change the directory's ownership: `sudo chown <user>:<user> ~/.openhands-state`.
|
||||
* or update permissions on the directory: `sudo chmod 777 ~/.openhands-state`
|
||||
* or delete it if you don’t need previous data. OpenHands will recreate it. You'll need to re-enter LLM settings.
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
|
||||
8
docs/package-lock.json
generated
8
docs/package-lock.json
generated
@@ -24,7 +24,7 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
@@ -17638,9 +17638,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -9307,10 +9307,10 @@ typedarray-to-buffer@^3.1.5:
|
||||
dependencies:
|
||||
is-typedarray "^1.0.0"
|
||||
|
||||
typescript@~5.8.2:
|
||||
version "5.8.2"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz"
|
||||
integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==
|
||||
typescript@~5.8.3:
|
||||
version "5.8.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
|
||||
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
|
||||
|
||||
undici-types@~5.26.4:
|
||||
version "5.26.5"
|
||||
|
||||
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -74,6 +75,7 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -55,6 +56,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -61,6 +62,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
|
||||
# copy 'draft_editor' config if exists
|
||||
|
||||
@@ -19,6 +19,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -72,6 +73,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -86,6 +87,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -50,6 +51,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
@@ -131,10 +132,11 @@ def get_config(
|
||||
)
|
||||
)
|
||||
agent_config = AgentConfig(
|
||||
codeact_enable_jupyter=False,
|
||||
codeact_enable_browsing=RUN_WITH_BROWSING,
|
||||
codeact_enable_llm_editor=False,
|
||||
enable_jupyter=False,
|
||||
enable_browsing=RUN_WITH_BROWSING,
|
||||
enable_llm_editor=False,
|
||||
)
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -76,11 +77,13 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
agent_config = AgentConfig(
|
||||
function_calling=False,
|
||||
codeact_enable_jupyter=True,
|
||||
codeact_enable_browsing_delegate=True,
|
||||
enable_jupyter=True,
|
||||
enable_browsing=True,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
@@ -18,6 +18,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -66,6 +67,8 @@ def get_config(
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -54,6 +55,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -75,6 +76,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -96,6 +97,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -63,6 +64,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -121,6 +122,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -91,6 +92,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
@@ -84,39 +85,54 @@ I've already taken care of all changes to any of the test files described in the
|
||||
Also the development Python environment is already set up for you (i.e., all dependencies already installed), so you don't need to install other packages.
|
||||
Your task is to make the minimal changes to non-test files in the /workspace/{workspace_dir_name} directory to ensure the <issue_description> is satisfied.
|
||||
|
||||
Follow these steps to resolve the issue:
|
||||
Follow these phases to resolve the issue:
|
||||
|
||||
1. EXPLORATION: First, thoroughly explore the repository structure using tools like `find` and `grep`.
|
||||
- Identify all files mentioned in the problem statement
|
||||
- Locate where the issue occurs in the codebase
|
||||
- Understand the surrounding context and dependencies
|
||||
- Use `grep` to search for relevant functions, classes, or error messages
|
||||
Phase 1. READING: read the problem and reword it in clearer terms
|
||||
1.1 If there are code or config snippets. Express in words any best practices or conventions in them.
|
||||
1.2 Hightlight message errors, method names, variables, file names, stack traces, and technical details.
|
||||
1.3 Explain the problem in clear terms.
|
||||
1.4 Enumerate the steps to reproduce the problem.
|
||||
1.5 Hightlight any best practices to take into account when testing and fixing the issue
|
||||
|
||||
2. ANALYSIS: Based on your exploration, think carefully about the problem and propose 2-5 possible approaches to fix the issue.
|
||||
- Analyze the root cause of the problem
|
||||
- Consider trade-offs between different solutions
|
||||
- Select the most promising approach and explain your reasoning
|
||||
Phase 2. RUNNING: install and run the tests on the repository
|
||||
2.1 Follow the readme
|
||||
2.2 Install the environment and anything needed
|
||||
2.2 Iterate and figure out how to run the tests
|
||||
|
||||
3. TEST CREATION: Before implementing any fix, create a script to reproduce and verify the issue.
|
||||
- Look at existing test files in the repository to understand the test format/structure
|
||||
- Create a minimal reproduction script that demonstrates the issue
|
||||
- Run your script to confirm the error exists
|
||||
Phase 3. EXPLORATION: find the files that are related to the problem and possible solutions
|
||||
3.1 Use `grep` to search for relevant methods, classes, keywords and error messages.
|
||||
3.2 Identify all files related to the problem statement.
|
||||
3.3 Propose the methods and files to fix the issue and explain why.
|
||||
3.4 From the possible file locations, select the most likely location to fix the issue.
|
||||
|
||||
4. IMPLEMENTATION: Edit the source code to implement your chosen solution.
|
||||
- Make minimal, focused changes to fix the issue
|
||||
Phase 4. TEST CREATION: before implementing any fix, create a script to reproduce and verify the issue.
|
||||
4.1 Look at existing test files in the repository to understand the test format/structure.
|
||||
4.2 Create a minimal reproduction script that reproduces the located issue.
|
||||
4.3 Run the reproduction script to confirm you are reproducing the issue.
|
||||
4.4 Adjust the reproduction script as necessary.
|
||||
|
||||
5. VERIFICATION: Test your implementation thoroughly.
|
||||
- Run your reproduction script to verify the fix works
|
||||
- Add edge cases to your test script to ensure comprehensive coverage
|
||||
- Run existing tests related to the modified code to ensure you haven't broken anything
|
||||
Phase 5. FIX ANALYSIS: state clearly the problem and how to fix it
|
||||
5.1 State clearly what the problem is.
|
||||
5.2 State clearly where the problem is located.
|
||||
5.3 State clearly how the test reproduces the issue.
|
||||
5.4 State clearly the best practices to take into account in the fix.
|
||||
5.5 State clearly how to fix the problem.
|
||||
|
||||
6. FINAL REVIEW: Carefully re-read the problem description and compare your changes with the base commit {instance["base_commit"]}.
|
||||
- Ensure you've fully addressed all requirements
|
||||
- Run any tests in the repository related to:
|
||||
* The issue you are fixing
|
||||
* The files you modified
|
||||
* The functions you changed
|
||||
- If any tests fail, revise your implementation until all tests pass
|
||||
Phase 6. FIX IMPLEMENTATION: Edit the source code to implement your chosen solution.
|
||||
6.1 Make minimal, focused changes to fix the issue.
|
||||
|
||||
Phase 7. VERIFICATION: Test your implementation thoroughly.
|
||||
7.1 Run your reproduction script to verify the fix works.
|
||||
7.2 Add edge cases to your test script to ensure comprehensive coverage.
|
||||
7.3 Run existing tests related to the modified code to ensure you haven't broken anything.
|
||||
|
||||
8. FINAL REVIEW: Carefully re-read the problem description and compare your changes with the base commit {instance["base_commit"]}.
|
||||
8.1 Ensure you've fully addressed all requirements.
|
||||
8.2 Run any tests in the repository related to:
|
||||
8.2.1 The issue you are fixing
|
||||
8.2.2 The files you modified
|
||||
8.2.3 The functions you changed
|
||||
8.3 If any tests fail, revise your implementation until all tests pass
|
||||
|
||||
Be thorough in your exploration, testing, and reasoning. It's fine if your thinking process is lengthy - quality and completeness are more important than brevity.
|
||||
"""
|
||||
@@ -210,12 +226,13 @@ def get_config(
|
||||
)
|
||||
)
|
||||
agent_config = AgentConfig(
|
||||
codeact_enable_jupyter=False,
|
||||
codeact_enable_browsing=RUN_WITH_BROWSING,
|
||||
codeact_enable_llm_editor=False,
|
||||
enable_jupyter=False,
|
||||
enable_browsing=RUN_WITH_BROWSING,
|
||||
enable_llm_editor=False,
|
||||
condenser=metadata.condenser_config,
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
@@ -223,6 +240,7 @@ def get_config(
|
||||
def initialize_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required
|
||||
metadata: EvalMetadata,
|
||||
):
|
||||
"""Initialize the runtime for the agent.
|
||||
|
||||
@@ -562,7 +580,7 @@ def process_instance(
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
try:
|
||||
initialize_runtime(runtime, instance)
|
||||
initialize_runtime(runtime, instance, metadata)
|
||||
|
||||
message_action = get_instruction(instance, metadata)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
@@ -158,12 +159,13 @@ def get_config(
|
||||
)
|
||||
)
|
||||
agent_config = AgentConfig(
|
||||
codeact_enable_jupyter=False,
|
||||
codeact_enable_browsing=RUN_WITH_BROWSING,
|
||||
codeact_enable_llm_editor=False,
|
||||
enable_jupyter=False,
|
||||
enable_browsing=RUN_WITH_BROWSING,
|
||||
enable_llm_editor=False,
|
||||
condenser=metadata.condenser_config,
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ from typing import List
|
||||
import yaml
|
||||
from browsing import pre_login
|
||||
|
||||
from evaluation.utils.shared import get_default_sandbox_config_for_eval
|
||||
from evaluation.utils.shared import (
|
||||
get_default_sandbox_config_for_eval,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
@@ -58,12 +61,14 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(llm_config)
|
||||
if agent_config:
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
config.set_agent_config(agent_config)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = AgentConfig(
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -55,6 +56,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_agent_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -76,6 +77,8 @@ def get_config(
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -62,9 +62,9 @@ def get_config(
|
||||
)
|
||||
)
|
||||
agent_config = AgentConfig(
|
||||
codeact_enable_jupyter=True,
|
||||
codeact_enable_browsing=True,
|
||||
codeact_enable_llm_editor=False,
|
||||
enable_jupyter=True,
|
||||
enable_browsing=True,
|
||||
enable_llm_editor=False,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
@@ -160,6 +160,26 @@ def cleanup():
|
||||
process.join()
|
||||
|
||||
|
||||
def update_agent_config_for_eval(
|
||||
agent_config: AgentConfig | None = None,
|
||||
) -> AgentConfig:
|
||||
"""Update agent config with evaluation-specific settings.
|
||||
|
||||
Args:
|
||||
agent_config: The agent config to update. If None, a new AgentConfig will be created.
|
||||
|
||||
Returns:
|
||||
The updated agent config.
|
||||
"""
|
||||
if agent_config is None:
|
||||
agent_config = AgentConfig()
|
||||
|
||||
# Note: We're not disabling repository memory here as requested
|
||||
# This function can be used for other evaluation-specific settings
|
||||
|
||||
return agent_config
|
||||
|
||||
|
||||
def make_metadata(
|
||||
llm_config: LLMConfig,
|
||||
dataset_name: str,
|
||||
@@ -172,6 +192,8 @@ def make_metadata(
|
||||
agent_config: AgentConfig | None = None,
|
||||
condenser_config: CondenserConfig | None = None,
|
||||
) -> EvalMetadata:
|
||||
# Update agent config with evaluation-specific settings
|
||||
agent_config = update_agent_config_for_eval(agent_config)
|
||||
model_name = llm_config.model.split('/')[-1]
|
||||
model_path = model_name.replace(':', '_').replace('@', '-')
|
||||
eval_note = f'_N_{eval_note}' if eval_note else ''
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
"project": "./tsconfig.json",
|
||||
},
|
||||
"extends": [
|
||||
"airbnb",
|
||||
@@ -11,15 +11,12 @@
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@tanstack/query/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"prettier"
|
||||
"plugin:@tanstack/query/recommended",
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"rules": {
|
||||
"prettier/prettier": [
|
||||
"error"
|
||||
],
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
"import/extensions": [
|
||||
"error",
|
||||
@@ -27,32 +24,26 @@
|
||||
{
|
||||
"": "never",
|
||||
"ts": "never",
|
||||
"tsx": "never"
|
||||
}
|
||||
]
|
||||
"tsx": "never",
|
||||
},
|
||||
],
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
"version": "detect",
|
||||
},
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {
|
||||
// Allow state modification in reduce and Redux reducers
|
||||
"no-param-reassign": [
|
||||
"error",
|
||||
{
|
||||
"props": true,
|
||||
"ignorePropertyModificationsFor": [
|
||||
"acc",
|
||||
"state"
|
||||
]
|
||||
}
|
||||
"ignorePropertyModificationsFor": ["acc", "state"],
|
||||
},
|
||||
],
|
||||
// For https://stackoverflow.com/questions/55844608/stuck-with-eslint-error-i-e-separately-loops-should-be-avoided-in-favor-of-arra
|
||||
"no-restricted-syntax": "off",
|
||||
@@ -66,24 +57,19 @@
|
||||
2,
|
||||
{
|
||||
"required": {
|
||||
"some": [
|
||||
"nesting",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
"some": ["nesting", "id"],
|
||||
},
|
||||
},
|
||||
],
|
||||
"react/prop-types": "off",
|
||||
"react/no-array-index-key": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"react/react-in-jsx-scope": "off"
|
||||
"react/react-in-jsx-scope": "off",
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"**/tsconfig.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
"project": ["**/tsconfig.json"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npx lint-staged
|
||||
npm test
|
||||
@@ -37,4 +37,4 @@ describe("CopyToClipboardButton", () => {
|
||||
const button = screen.getByTestId("copy-to-clipboard");
|
||||
expect(button).toHaveAttribute("aria-label", "BUTTON$COPIED");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,6 +217,17 @@ describe("ChatInput", () => {
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("should use the default maxRows value", () => {
|
||||
// We can't directly test the maxRows prop as it's not exposed in the DOM
|
||||
// Instead, we'll verify the component renders with the default props
|
||||
render(<ChatInput onSubmit={onSubmitMock} />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toBeInTheDocument();
|
||||
|
||||
// The actual verification of maxRows=16 is handled internally by the TextareaAutosize component
|
||||
// and affects how many rows the textarea can expand to
|
||||
});
|
||||
|
||||
it("should not submit when Enter is pressed during IME composition", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSubmit={onSubmitMock} />);
|
||||
|
||||
@@ -76,11 +76,11 @@ describe("ConversationCard", () => {
|
||||
const card = screen.getByTestId("conversation-card");
|
||||
|
||||
within(card).getByText("Conversation 1");
|
||||
|
||||
|
||||
// Just check that the card contains the expected text content
|
||||
expect(card).toHaveTextContent("Created");
|
||||
expect(card).toHaveTextContent("ago");
|
||||
|
||||
|
||||
// Use a regex to match the time part since it might have whitespace
|
||||
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
|
||||
expect(card).toHaveTextContent(timeRegex);
|
||||
|
||||
51
frontend/__tests__/services/observations.test.ts
Normal file
51
frontend/__tests__/services/observations.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleObservationMessage } from "#/services/observations";
|
||||
import store from "#/store";
|
||||
import { ObservationMessage } from "#/types/message";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
dispatch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Observations Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleObservationMessage", () => {
|
||||
const createErrorMessage = (): ObservationMessage => ({
|
||||
id: 14,
|
||||
timestamp: "2025-04-14T13:37:54.451843",
|
||||
message: "The action has not been executed.",
|
||||
cause: 12,
|
||||
observation: "error",
|
||||
content: "The action has not been executed.",
|
||||
extras: {
|
||||
error_id: "",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
it("should dispatch error messages exactly once", () => {
|
||||
const errorMessage = createErrorMessage();
|
||||
|
||||
handleObservationMessage(errorMessage);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.dispatch).toHaveBeenCalledWith({
|
||||
type: "chat/addAssistantObservation",
|
||||
payload: expect.objectContaining({
|
||||
observation: "error",
|
||||
content: "The action has not been executed.",
|
||||
source: "user",
|
||||
extras: {
|
||||
error_id: "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -83,6 +83,7 @@
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
@@ -9173,6 +9174,22 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-unused-imports": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
|
||||
"integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
|
||||
"eslint": "^9.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev",
|
||||
"dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev",
|
||||
"build": "npm run make-i18n && npm run typecheck && react-router build",
|
||||
"build": "npm run make-i18n && react-router build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
@@ -62,7 +62,7 @@
|
||||
"preview": "vite preview",
|
||||
"make-i18n": "node scripts/make-i18n-translations.cjs",
|
||||
"prelint": "npm run make-i18n",
|
||||
"lint": "eslint src --ext .ts,.tsx,.js && prettier --check src/**/*.{ts,tsx}",
|
||||
"lint": "npm run typecheck && eslint src --ext .ts,.tsx,.js && prettier --check src/**/*.{ts,tsx}",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
|
||||
"prepare": "cd .. && husky frontend/.husky",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
@@ -107,6 +107,7 @@
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
|
||||
@@ -47,6 +47,7 @@ const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
||||
// Attributes that typically don't contain user-facing text
|
||||
const NON_TEXT_ATTRIBUTES = [
|
||||
"className",
|
||||
"i18nKey",
|
||||
"testId",
|
||||
"id",
|
||||
"name",
|
||||
@@ -685,23 +686,23 @@ function scanDirectoryForUnlocalizedStrings(dirPath) {
|
||||
try {
|
||||
const srcPath = path.resolve(__dirname, '../src');
|
||||
console.log('Checking for unlocalized strings in frontend code...');
|
||||
|
||||
|
||||
// Get unlocalized strings using the AST scanner
|
||||
const results = scanDirectoryForUnlocalizedStrings(srcPath);
|
||||
|
||||
|
||||
// If we found any unlocalized strings, format them for output and exit with error
|
||||
if (results.size > 0) {
|
||||
const formattedResults = Array.from(results.entries())
|
||||
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
|
||||
.join('\n');
|
||||
|
||||
|
||||
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
console.log('✅ No unlocalized strings found in frontend code.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error running unlocalized strings check:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChatInput({
|
||||
disabled,
|
||||
showButton = true,
|
||||
value,
|
||||
maxRows = 4,
|
||||
maxRows = 16,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onChange,
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ChatMessage({
|
||||
"rounded-xl relative",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
|
||||
type === "assistant" && "mt-6 max-w-full bg-tranparent",
|
||||
type === "assistant" && "mt-6 max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
<CopyToClipboardButton
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AgentStatusBar() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (curAgentState === AgentState.LOADING && message.trim()) {
|
||||
if (message.trim()) {
|
||||
setStatusMessage(message);
|
||||
} else {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
|
||||
@@ -4961,19 +4961,19 @@
|
||||
"tr": "Gezinme tamamlandı"
|
||||
},
|
||||
"OBSERVATION_MESSAGE$RECALL": {
|
||||
"en": "MicroAgent Activated",
|
||||
"en": "Microagent Activated",
|
||||
"ja": "マイクロエージェントが有効化されました",
|
||||
"zh-CN": "微代理已激活",
|
||||
"zh-TW": "微代理已啟動",
|
||||
"ko-KR": "마이크로에이전트 활성화됨",
|
||||
"no": "MikroAgent aktivert",
|
||||
"it": "MicroAgent attivato",
|
||||
"pt": "MicroAgent ativado",
|
||||
"es": "MicroAgent activado",
|
||||
"it": "Microagent attivato",
|
||||
"pt": "Microagent ativado",
|
||||
"es": "Microagent activado",
|
||||
"ar": "تم تنشيط الوكيل المصغر",
|
||||
"fr": "MicroAgent activé",
|
||||
"fr": "Microagent activé",
|
||||
"tr": "MikroAjan Etkinleştirildi",
|
||||
"de": "MicroAgent aktiviert"
|
||||
"de": "Microagent aktiviert"
|
||||
},
|
||||
"EXPANDABLE_MESSAGE$SHOW_DETAILS": {
|
||||
"en": "Show details",
|
||||
@@ -6089,4 +6089,4 @@
|
||||
"tr": "belgelendirme",
|
||||
"de": "Dokumentation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ function AccountSettings() {
|
||||
formData.get("enable-memory-condenser-switch")?.toString() === "on";
|
||||
const enableSoundNotifications =
|
||||
formData.get("enable-sound-notifications-switch")?.toString() === "on";
|
||||
const llmBaseUrl = formData.get("base-url-input")?.toString() || "";
|
||||
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
|
||||
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
|
||||
const llmApiKey =
|
||||
inputApiKey === "" && isLLMKeySet
|
||||
|
||||
@@ -121,7 +121,7 @@ function AppContent() {
|
||||
function renderMain() {
|
||||
if (width <= 640) {
|
||||
return (
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full">
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full bg-base-secondary">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -104,7 +104,12 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
}
|
||||
|
||||
if (message.source === "agent") {
|
||||
if (message.args && message.args.thought) {
|
||||
// Only add thought as a message if it's not a "think" action
|
||||
if (
|
||||
message.args &&
|
||||
message.args.thought &&
|
||||
message.action !== ActionType.THINK
|
||||
) {
|
||||
store.dispatch(addAssistantMessage(message.args.thought));
|
||||
}
|
||||
// Need to convert ActionMessage to RejectAction
|
||||
|
||||
@@ -52,6 +52,7 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
case ObservationType.THINK:
|
||||
case ObservationType.NULL:
|
||||
case ObservationType.RECALL:
|
||||
case ObservationType.ERROR:
|
||||
break; // We don't display the default message for these observations
|
||||
default:
|
||||
store.dispatch(addAssistantMessage(message.message));
|
||||
|
||||
@@ -24,6 +24,7 @@ const HANDLED_ACTIONS: OpenHandsEventType[] = [
|
||||
"browse_interactive",
|
||||
"edit",
|
||||
"recall",
|
||||
"think",
|
||||
];
|
||||
|
||||
function getRiskText(risk: ActionSecurityRisk) {
|
||||
|
||||
@@ -32,6 +32,9 @@ enum ObservationType {
|
||||
// An observation that shows agent's context extension
|
||||
RECALL = "recall",
|
||||
|
||||
// An error observation
|
||||
ERROR = "error",
|
||||
|
||||
// A no-op observation
|
||||
NULL = "null",
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { downloadJSON } from "./download-json";
|
||||
|
||||
function isSaveFilePickerSupported(): boolean {
|
||||
return typeof window !== "undefined" && "showSaveFilePicker" in window;
|
||||
}
|
||||
@@ -6,25 +8,37 @@ export async function downloadTrajectory(
|
||||
conversationId: string,
|
||||
data: unknown[] | null,
|
||||
): Promise<void> {
|
||||
if (!isSaveFilePickerSupported()) {
|
||||
throw new Error(
|
||||
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API.",
|
||||
);
|
||||
}
|
||||
const options: SaveFilePickerOptions = {
|
||||
suggestedName: `trajectory-${conversationId}.json`,
|
||||
types: [
|
||||
{
|
||||
description: "JSON File", // This is a file type description, not user-facing text
|
||||
accept: {
|
||||
"application/json": [".json"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// Ensure data is an object for downloadJSON
|
||||
const jsonData = data || {};
|
||||
|
||||
const fileHandle = await window.showSaveFilePicker(options);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
if (!isSaveFilePickerSupported()) {
|
||||
// Fallback for browsers that don't support File System Access API (Safari, Firefox)
|
||||
// This method dumps the JSON data right into the download folder
|
||||
downloadJSON(jsonData as object, `trajectory-${conversationId}.json`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options: SaveFilePickerOptions = {
|
||||
suggestedName: `trajectory-${conversationId}.json`,
|
||||
types: [
|
||||
{
|
||||
description: "JSON File", // This is a file type description, not user-facing text
|
||||
accept: {
|
||||
"application/json": [".json"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fileHandle = await window.showSaveFilePicker(options);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
// If an error occurs, fall back to the downloadJSON method
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
downloadJSON(jsonData as object, `trajectory-${conversationId}.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenHands MicroAgents
|
||||
# OpenHands Microagents
|
||||
|
||||
MicroAgents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They help developers by providing expert guidance, automating common tasks, and ensuring consistent practices across projects. Each microagent is designed to excel in a specific area, from Git operations to code review processes.
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They help developers by providing expert guidance, automating common tasks, and ensuring consistent practices across projects. Each microagent is designed to excel in a specific area, from Git operations to code review processes.
|
||||
|
||||
## Sources of Microagents
|
||||
|
||||
@@ -49,7 +49,7 @@ When OpenHands works with a repository, it:
|
||||
2. Loads relevant knowledge agents based on keywords in conversations
|
||||
3. Enable task agent if user select one of them
|
||||
|
||||
## Types of MicroAgents
|
||||
## Types of Microagents
|
||||
|
||||
Most microagents use markdown files with YAML frontmatter. For repository agents (repo.md), the frontmatter is optional - if not provided, the file will be loaded with default settings as a repository agent.
|
||||
|
||||
|
||||
@@ -30,6 +30,6 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
|
||||
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
-H "Authorization: Bearer $GITLAB_TOKEN" \
|
||||
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
|
||||
```
|
||||
|
||||
@@ -53,9 +53,9 @@ The agent provides several built-in tools:
|
||||
## Configuration
|
||||
|
||||
Tools can be enabled/disabled through configuration parameters:
|
||||
- `codeact_enable_browsing`: Enable browser interaction tools
|
||||
- `codeact_enable_jupyter`: Enable IPython code execution
|
||||
- `codeact_enable_llm_editor`: Enable LLM-based file editing (falls back to string replacement editor if disabled)
|
||||
- `enable_browsing`: Enable browser interaction tools
|
||||
- `enable_jupyter`: Enable IPython code execution
|
||||
- `enable_llm_editor`: Enable LLM-based file editing (falls back to string replacement editor if disabled)
|
||||
|
||||
## Micro-agents
|
||||
|
||||
|
||||
@@ -62,21 +62,21 @@ class CodeActAgent(Agent):
|
||||
|
||||
Parameters:
|
||||
- llm (LLM): The llm to be used by this agent
|
||||
- config (AgentConfig): The configuration for this agent
|
||||
"""
|
||||
super().__init__(llm, config)
|
||||
self.pending_actions: deque[Action] = deque()
|
||||
self.reset()
|
||||
|
||||
# Retrieve the enabled tools
|
||||
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,
|
||||
built_in_tools = codeact_function_calling.get_tools(
|
||||
enable_browsing=self.config.enable_browsing,
|
||||
enable_jupyter=self.config.enable_jupyter,
|
||||
enable_llm_editor=self.config.enable_llm_editor,
|
||||
llm=self.llm,
|
||||
)
|
||||
logger.debug(
|
||||
f"TOOLS loaded for CodeActAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}"
|
||||
)
|
||||
|
||||
self.tools = built_in_tools
|
||||
|
||||
self.prompt_manager = PromptManager(
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
|
||||
)
|
||||
@@ -137,10 +137,23 @@ class CodeActAgent(Agent):
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
}
|
||||
params['tools'] = self.tools
|
||||
|
||||
if self.mcp_tools:
|
||||
# Only add tools with unique names
|
||||
existing_names = {tool['function']['name'] for tool in params['tools']}
|
||||
unique_mcp_tools = [
|
||||
tool
|
||||
for tool in self.mcp_tools
|
||||
if tool['function']['name'] not in existing_names
|
||||
]
|
||||
params['tools'] += unique_mcp_tools
|
||||
|
||||
# log to litellm proxy if possible
|
||||
params['extra_body'] = {'metadata': state.to_llm_metadata(agent_name=self.name)}
|
||||
response = self.llm.completion(**params)
|
||||
logger.debug(f'Response from LLM: {response}')
|
||||
actions = codeact_function_calling.response_to_actions(response)
|
||||
logger.debug(f'Actions after response_to_actions: {actions}')
|
||||
for action in actions:
|
||||
self.pending_actions.append(action)
|
||||
return self.pending_actions.popleft()
|
||||
|
||||
@@ -24,6 +24,7 @@ from openhands.core.exceptions import (
|
||||
FunctionCallNotExistsError,
|
||||
FunctionCallValidationError,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
AgentDelegateAction,
|
||||
@@ -37,9 +38,11 @@ from openhands.events.action import (
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.action.mcp import McpAction
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm import LLM
|
||||
from openhands.mcp import MCPClientTool
|
||||
|
||||
|
||||
def combine_thought(action: Action, thought: str) -> Action:
|
||||
@@ -70,6 +73,7 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
# Process each tool call to OpenHands action
|
||||
for i, tool_call in enumerate(assistant_msg.tool_calls):
|
||||
action: Action
|
||||
logger.debug(f'Tool call in function_calling.py: {tool_call}')
|
||||
try:
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
@@ -191,6 +195,15 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
f'Missing required argument "url" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseURLAction(url=arguments['url'])
|
||||
|
||||
# ================================================
|
||||
# McpAction (MCP)
|
||||
# ================================================
|
||||
elif tool_call.function.name.endswith(MCPClientTool.postfix()):
|
||||
action = McpAction(
|
||||
name=tool_call.function.name.rstrip(MCPClientTool.postfix()),
|
||||
arguments=tool_call.function.arguments,
|
||||
)
|
||||
else:
|
||||
raise FunctionCallNotExistsError(
|
||||
f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'
|
||||
@@ -227,9 +240,9 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
|
||||
|
||||
def get_tools(
|
||||
codeact_enable_browsing: bool = False,
|
||||
codeact_enable_llm_editor: bool = False,
|
||||
codeact_enable_jupyter: bool = False,
|
||||
enable_browsing: bool = False,
|
||||
enable_llm_editor: bool = False,
|
||||
enable_jupyter: bool = False,
|
||||
llm: LLM | None = None,
|
||||
) -> list[ChatCompletionToolParam]:
|
||||
SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1']
|
||||
@@ -246,12 +259,12 @@ def get_tools(
|
||||
ThinkTool,
|
||||
FinishTool,
|
||||
]
|
||||
if codeact_enable_browsing:
|
||||
if enable_browsing:
|
||||
tools.append(WebReadTool)
|
||||
tools.append(BrowserTool)
|
||||
if codeact_enable_jupyter:
|
||||
if enable_jupyter:
|
||||
tools.append(IPythonTool)
|
||||
if codeact_enable_llm_editor:
|
||||
if enable_llm_editor:
|
||||
tools.append(LLMBasedFileEditTool)
|
||||
else:
|
||||
tools.append(
|
||||
|
||||
@@ -37,6 +37,7 @@ class Agent(ABC):
|
||||
self.config = config
|
||||
self._complete = False
|
||||
self.prompt_manager: 'PromptManager' | None = None
|
||||
self.mcp_tools: list[dict] = []
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
@@ -111,3 +112,11 @@ class Agent(ABC):
|
||||
if not bool(cls._registry):
|
||||
raise AgentNotRegisteredError()
|
||||
return list(cls._registry.keys())
|
||||
|
||||
def set_mcp_tools(self, mcp_tools: list[dict]) -> None:
|
||||
"""Sets the list of MCP tools for the agent.
|
||||
|
||||
Args:
|
||||
- mcp_tools (list[dict]): The list of MCP tools.
|
||||
"""
|
||||
self.mcp_tools = mcp_tools
|
||||
|
||||
@@ -66,7 +66,7 @@ from openhands.events.observation import (
|
||||
)
|
||||
from openhands.events.serialization.event import event_to_trajectory, truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics, TokenUsage
|
||||
from openhands.llm.metrics import Metrics
|
||||
|
||||
# note: RESUME is only available on web GUI
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
@@ -1114,36 +1114,38 @@ class AgentController:
|
||||
|
||||
To avoid performance issues with long conversations, we only keep:
|
||||
- accumulated_cost: The current total cost
|
||||
- latest token_usage: Token statistics from the most recent API call
|
||||
- accumulated_token_usage: Accumulated token statistics across all API calls
|
||||
|
||||
Args:
|
||||
action: The action to attach metrics to
|
||||
"""
|
||||
# Create a minimal metrics object with just what the frontend needs
|
||||
metrics = Metrics(model_name=self.agent.llm.metrics.model_name)
|
||||
metrics.accumulated_cost = self.agent.llm.metrics.accumulated_cost
|
||||
if self.agent.llm.metrics.token_usages:
|
||||
latest_usage = self.agent.llm.metrics.token_usages[-1]
|
||||
metrics.add_token_usage(
|
||||
prompt_tokens=latest_usage.prompt_tokens,
|
||||
completion_tokens=latest_usage.completion_tokens,
|
||||
cache_read_tokens=latest_usage.cache_read_tokens,
|
||||
cache_write_tokens=latest_usage.cache_write_tokens,
|
||||
response_id=latest_usage.response_id,
|
||||
)
|
||||
metrics._accumulated_token_usage = (
|
||||
self.agent.llm.metrics.accumulated_token_usage
|
||||
)
|
||||
|
||||
action.llm_metrics = metrics
|
||||
|
||||
# Log the metrics information for frontend display
|
||||
log_usage: TokenUsage | None = (
|
||||
metrics.token_usages[-1] if metrics.token_usages else None
|
||||
)
|
||||
# Log the metrics information for debugging
|
||||
# Get the latest usage directly from the agent's metrics
|
||||
latest_usage = None
|
||||
if self.agent.llm.metrics.token_usages:
|
||||
latest_usage = self.agent.llm.metrics.token_usages[-1]
|
||||
|
||||
accumulated_usage = self.agent.llm.metrics.accumulated_token_usage
|
||||
self.log(
|
||||
'debug',
|
||||
f'Action metrics - accumulated_cost: {metrics.accumulated_cost}, '
|
||||
f'tokens (prompt/completion/cache_read/cache_write): '
|
||||
f'{log_usage.prompt_tokens if log_usage else 0}/'
|
||||
f'{log_usage.completion_tokens if log_usage else 0}/'
|
||||
f'{log_usage.cache_read_tokens if log_usage else 0}/'
|
||||
f'{log_usage.cache_write_tokens if log_usage else 0}',
|
||||
f'latest tokens (prompt/completion/cache_read/cache_write): '
|
||||
f'{latest_usage.prompt_tokens if latest_usage else 0}/'
|
||||
f'{latest_usage.completion_tokens if latest_usage else 0}/'
|
||||
f'{latest_usage.cache_read_tokens if latest_usage else 0}/'
|
||||
f'{latest_usage.cache_write_tokens if latest_usage else 0}, '
|
||||
f'accumulated tokens (prompt/completion): '
|
||||
f'{accumulated_usage.prompt_tokens}/'
|
||||
f'{accumulated_usage.completion_tokens}',
|
||||
extra={'msg_type': 'METRICS'},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import toml
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
from prompt_toolkit.formatted_text import HTML, FormattedText
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.layout import Layout
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.shortcuts import clear, print_container
|
||||
from prompt_toolkit.styles import Style
|
||||
from prompt_toolkit.widgets import Frame, TextArea
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
from openhands import __version__
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
parse_arguments,
|
||||
@@ -37,88 +51,176 @@ from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
CmdOutputObservation,
|
||||
FileEditObservation,
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.io import read_task
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.mcp import fetch_mcp_tools_from_config
|
||||
from openhands.microagent.microagent import BaseMicroagent
|
||||
|
||||
prompt_session = PromptSession()
|
||||
# Color and styling constants
|
||||
COLOR_GOLD = '#FFD700'
|
||||
COLOR_GREY = '#808080'
|
||||
DEFAULT_STYLE = Style.from_dict(
|
||||
{
|
||||
'gold': COLOR_GOLD,
|
||||
'grey': COLOR_GREY,
|
||||
'prompt': f'{COLOR_GOLD} bold',
|
||||
}
|
||||
)
|
||||
|
||||
COMMANDS = {
|
||||
'/exit': 'Exit the application',
|
||||
'/help': 'Display available commands',
|
||||
'/init': 'Initialize a new repository',
|
||||
}
|
||||
|
||||
REPO_MD_CREATE_PROMPT = """
|
||||
Please explore this repository. Create the file .openhands/microagents/repo.md with:
|
||||
- A description of the project
|
||||
- An overview of the file structure
|
||||
- Any information on how to run tests or other relevant commands
|
||||
- Any other information that would be helpful to a brand new developer
|
||||
Keep it short--just a few paragraphs will do.
|
||||
"""
|
||||
|
||||
|
||||
def display_message(message: str) -> None:
|
||||
class CommandCompleter(Completer):
|
||||
"""Custom completer for commands."""
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text
|
||||
|
||||
# Only show completions if the user has typed '/'
|
||||
if text.startswith('/'):
|
||||
# If just '/' is typed, show all commands
|
||||
if text == '/':
|
||||
for command, description in COMMANDS.items():
|
||||
yield Completion(
|
||||
command[1:], # Remove the leading '/' as it's already typed
|
||||
start_position=0,
|
||||
display=f'{command} - {description}',
|
||||
)
|
||||
# Otherwise show matching commands
|
||||
else:
|
||||
for command, description in COMMANDS.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
command[len(text) :], # Complete the remaining part
|
||||
start_position=0,
|
||||
display=f'{command} - {description}',
|
||||
)
|
||||
|
||||
|
||||
class UsageMetrics:
|
||||
def __init__(self):
|
||||
self.total_cost: float = 0.00
|
||||
self.total_input_tokens: int = 0
|
||||
self.total_output_tokens: int = 0
|
||||
self.total_cache_read: int = 0
|
||||
self.total_cache_write: int = 0
|
||||
|
||||
|
||||
prompt_session = PromptSession(style=DEFAULT_STYLE, completer=CommandCompleter())
|
||||
|
||||
|
||||
def display_message(message: str):
|
||||
message = message.strip()
|
||||
|
||||
if message:
|
||||
print_formatted_text(f'\n{message}\n')
|
||||
|
||||
|
||||
def display_command(command: str):
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=command,
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='Command Run',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(container)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_confirmation(confirmation_state: ActionConfirmationStatus):
|
||||
status_map = {
|
||||
ActionConfirmationStatus.CONFIRMED: ('ansigreen', '✅'),
|
||||
ActionConfirmationStatus.REJECTED: ('ansired', '❌'),
|
||||
ActionConfirmationStatus.AWAITING_CONFIRMATION: ('ansiyellow', '⏳'),
|
||||
}
|
||||
color, icon = status_map.get(confirmation_state, ('ansiyellow', ''))
|
||||
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('ansiyellow', '🤖 '),
|
||||
('ansiyellow', message),
|
||||
(color, f'{icon} '),
|
||||
(color, str(confirmation_state)),
|
||||
('', '\n'),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def display_command(command: str) -> None:
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('', '❯ '),
|
||||
('ansigreen', command),
|
||||
('', '\n'),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def display_confirmation(confirmation_state: ActionConfirmationStatus) -> None:
|
||||
if confirmation_state == ActionConfirmationStatus.CONFIRMED:
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('ansigreen', '✅ '),
|
||||
('ansigreen', str(confirmation_state)),
|
||||
('', '\n'),
|
||||
]
|
||||
)
|
||||
)
|
||||
elif confirmation_state == ActionConfirmationStatus.REJECTED:
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('ansired', '❌ '),
|
||||
('ansired', str(confirmation_state)),
|
||||
('', '\n'),
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('ansiyellow', '⏳ '),
|
||||
('ansiyellow', str(confirmation_state)),
|
||||
('', '\n'),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def display_command_output(output: str) -> None:
|
||||
def display_command_output(output: str):
|
||||
lines = output.split('\n')
|
||||
formatted_lines = []
|
||||
for line in lines:
|
||||
if line.startswith('[Python Interpreter') or line.startswith('openhands@'):
|
||||
# TODO: clean this up once we clean up terminal output
|
||||
continue
|
||||
print_formatted_text(FormattedText([('ansiblue', line)]))
|
||||
formatted_lines.append(line)
|
||||
formatted_lines.append('\n')
|
||||
|
||||
# Remove the last newline if it exists
|
||||
if formatted_lines:
|
||||
formatted_lines.pop()
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=''.join(formatted_lines),
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='Command Output',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(container)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_file_edit(event: FileEditAction | FileEditObservation) -> None:
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('ansigreen', str(event)),
|
||||
('', '\n'),
|
||||
]
|
||||
)
|
||||
def display_file_edit(event: FileEditAction | FileEditObservation):
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=f'{event}',
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='File Edit',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(container)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_file_read(event: FileReadObservation):
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=f'{event}',
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='File Read',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(container)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_event(event: Event, config: AppConfig) -> None:
|
||||
@@ -136,10 +238,115 @@ def display_event(event: Event, config: AppConfig) -> None:
|
||||
display_file_edit(event)
|
||||
if isinstance(event, FileEditObservation):
|
||||
display_file_edit(event)
|
||||
if isinstance(event, FileReadObservation):
|
||||
display_file_read(event)
|
||||
if hasattr(event, 'confirmation_state') and config.security.confirmation_mode:
|
||||
display_confirmation(event.confirmation_state)
|
||||
|
||||
|
||||
def display_help(style=DEFAULT_STYLE):
|
||||
print_formatted_text(
|
||||
HTML(f'\n<grey>OpenHands CLI v{__version__}</grey>\n'), style=style
|
||||
)
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<gold>OpenHands CLI lets you interact with the OpenHands agent from the command line.</gold>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
print_formatted_text('Things that you can try:')
|
||||
print_formatted_text(
|
||||
HTML('• Ask questions about the codebase <grey>> How does main.py work?</grey>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'• Edit files or add new features <grey>> Add a new function to ...</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('• Find and fix issues <grey>> Fix the type error in ...</grey>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
print_formatted_text('Some tips to get the most out of OpenHands:')
|
||||
print_formatted_text(
|
||||
'• Be as specific as possible about the desired outcome or the problem to be solved.'
|
||||
)
|
||||
print_formatted_text(
|
||||
'• Provide context, including relevant file paths and line numbers if available.'
|
||||
)
|
||||
print_formatted_text('• Break large tasks into smaller, manageable prompts.')
|
||||
print_formatted_text('• Include relevant error messages or logs.')
|
||||
print_formatted_text(
|
||||
'• Specify the programming language or framework, if not obvious.'
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
print_formatted_text(HTML('Interactive commands:'), style=style)
|
||||
for command, description in COMMANDS.items():
|
||||
print_formatted_text(
|
||||
HTML(f'<gold><b>{command}</b></gold> - <grey>{description}</grey>'),
|
||||
style=style,
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>Learn more at: https://docs.all-hands.dev/modules/usage/getting-started</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_banner(session_id: str, is_loaded: asyncio.Event):
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
___ _ _ _
|
||||
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
|
||||
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
|
||||
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
|
||||
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|
||||
|_|
|
||||
</gold>"""),
|
||||
style=DEFAULT_STYLE,
|
||||
)
|
||||
|
||||
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
|
||||
|
||||
banner_text = (
|
||||
'Initialized session' if is_loaded.is_set() else 'Initializing session'
|
||||
)
|
||||
print_formatted_text(HTML(f'\n<grey>{banner_text} {session_id}</grey>\n'))
|
||||
|
||||
|
||||
def display_welcome_message():
|
||||
print_formatted_text(
|
||||
HTML("<gold>Let's start building!</gold>\n"), style=DEFAULT_STYLE
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('What do you want to build? <grey>Type /help for help</grey>\n'),
|
||||
style=DEFAULT_STYLE,
|
||||
)
|
||||
|
||||
|
||||
def display_initialization_animation(text, is_loaded: asyncio.Event):
|
||||
ANIMATION_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
i = 0
|
||||
while not is_loaded.is_set():
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(
|
||||
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
async def read_prompt_input(multiline=False):
|
||||
try:
|
||||
if multiline:
|
||||
@@ -149,35 +356,292 @@ async def read_prompt_input(multiline=False):
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
message = await prompt_session.prompt_async(
|
||||
'Enter your message and press Ctrl+D to finish:\n',
|
||||
multiline=True,
|
||||
key_bindings=kb,
|
||||
)
|
||||
with patch_stdout():
|
||||
message = await prompt_session.prompt_async(
|
||||
'Enter your message and press Ctrl+D to finish:\n',
|
||||
multiline=True,
|
||||
key_bindings=kb,
|
||||
)
|
||||
else:
|
||||
message = await prompt_session.prompt_async(
|
||||
'>> ',
|
||||
)
|
||||
with patch_stdout():
|
||||
message = await prompt_session.prompt_async(
|
||||
'> ',
|
||||
)
|
||||
return message
|
||||
except KeyboardInterrupt:
|
||||
return 'exit'
|
||||
return '/exit'
|
||||
except EOFError:
|
||||
return 'exit'
|
||||
return '/exit'
|
||||
|
||||
|
||||
async def read_confirmation_input():
|
||||
try:
|
||||
confirmation = await prompt_session.prompt_async(
|
||||
'Confirm action (possible security risk)? (y/n) >> ',
|
||||
'Confirm action (possible security risk)? (y/n) > ',
|
||||
)
|
||||
return confirmation.lower() == 'y'
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return False
|
||||
|
||||
|
||||
async def main(loop: asyncio.AbstractEventLoop) -> None:
|
||||
async def init_repository(current_dir: str) -> bool:
|
||||
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
|
||||
init_repo = False
|
||||
|
||||
if repo_file_path.exists():
|
||||
try:
|
||||
content = await asyncio.get_event_loop().run_in_executor(
|
||||
None, read_file, repo_file_path
|
||||
)
|
||||
|
||||
print_formatted_text(
|
||||
'Repository instructions file (repo.md) already exists.\n'
|
||||
)
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=content,
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='Repository Instructions (repo.md)',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(container)
|
||||
print_formatted_text('') # Add a newline after the frame
|
||||
|
||||
init_repo = cli_confirm(
|
||||
'Do you want to re-initialize?',
|
||||
['Yes, re-initialize', 'No, dismiss'],
|
||||
)
|
||||
|
||||
if init_repo:
|
||||
write_to_file(repo_file_path, '')
|
||||
except Exception:
|
||||
print_formatted_text('Error reading repository instructions file (repo.md)')
|
||||
init_repo = False
|
||||
else:
|
||||
print_formatted_text(
|
||||
'\nRepository instructions file will be created by exploring the repository.\n'
|
||||
)
|
||||
|
||||
init_repo = cli_confirm(
|
||||
'Do you want to proceed?',
|
||||
['Yes, create', 'No, dismiss'],
|
||||
)
|
||||
|
||||
return init_repo
|
||||
|
||||
|
||||
def read_file(file_path):
|
||||
with open(file_path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_to_file(file_path, content):
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def cli_confirm(question: str = 'Are you sure?', choices: Optional[List[str]] = None):
|
||||
if choices is None:
|
||||
choices = ['Yes', 'No']
|
||||
selected = [0] # Using list to allow modification in closure
|
||||
|
||||
def get_choice_text():
|
||||
return [
|
||||
('class:question', f'{question}\n\n'),
|
||||
] + [
|
||||
(
|
||||
'class:selected' if i == selected[0] else 'class:unselected',
|
||||
f"{'> ' if i == selected[0] else ' '}{choice}\n",
|
||||
)
|
||||
for i, choice in enumerate(choices)
|
||||
]
|
||||
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('up')
|
||||
def _(event):
|
||||
selected[0] = (selected[0] - 1) % len(choices)
|
||||
|
||||
@kb.add('down')
|
||||
def _(event):
|
||||
selected[0] = (selected[0] + 1) % len(choices)
|
||||
|
||||
@kb.add('enter')
|
||||
def _(event):
|
||||
event.app.exit(result=selected[0] == 0)
|
||||
|
||||
style = Style.from_dict({'selected': COLOR_GOLD, 'unselected': ''})
|
||||
|
||||
layout = Layout(
|
||||
HSplit(
|
||||
[
|
||||
Window(
|
||||
FormattedTextControl(get_choice_text),
|
||||
always_hide_cursor=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
app = Application(
|
||||
layout=layout,
|
||||
key_bindings=kb,
|
||||
style=style,
|
||||
mouse_support=True,
|
||||
full_screen=False,
|
||||
)
|
||||
|
||||
return app.run(in_thread=True)
|
||||
|
||||
|
||||
def update_usage_metrics(event: Event, usage_metrics: UsageMetrics):
|
||||
"""Updates the UsageMetrics object with data from an event's llm_metrics."""
|
||||
if hasattr(event, 'llm_metrics'):
|
||||
llm_metrics: Metrics | None = getattr(event, 'llm_metrics', None)
|
||||
if llm_metrics:
|
||||
# Safely get accumulated_cost
|
||||
cost = getattr(llm_metrics, 'accumulated_cost', 0)
|
||||
# Ensure cost is a number before adding
|
||||
usage_metrics.total_cost += cost if isinstance(cost, float) else 0
|
||||
|
||||
# Safely get token usage details object/dict
|
||||
token_usage = getattr(llm_metrics, 'accumulated_token_usage', None)
|
||||
if token_usage:
|
||||
# Assume object access using getattr, providing defaults
|
||||
prompt_tokens = getattr(token_usage, 'prompt_tokens', 0)
|
||||
completion_tokens = getattr(token_usage, 'completion_tokens', 0)
|
||||
cache_read = getattr(token_usage, 'cache_read_tokens', 0)
|
||||
cache_write = getattr(token_usage, 'cache_write_tokens', 0)
|
||||
|
||||
# Ensure tokens are numbers before adding
|
||||
usage_metrics.total_input_tokens += (
|
||||
prompt_tokens if isinstance(prompt_tokens, int) else 0
|
||||
)
|
||||
usage_metrics.total_output_tokens += (
|
||||
completion_tokens if isinstance(completion_tokens, int) else 0
|
||||
)
|
||||
usage_metrics.total_cache_read += (
|
||||
cache_read if isinstance(cache_read, int) else 0
|
||||
)
|
||||
usage_metrics.total_cache_write += (
|
||||
cache_write if isinstance(cache_write, int) else 0
|
||||
)
|
||||
|
||||
|
||||
def shutdown(usage_metrics: UsageMetrics, session_id: str):
|
||||
cost_str = f'${usage_metrics.total_cost:.6f}'
|
||||
input_tokens_str = f'{usage_metrics.total_input_tokens:,}'
|
||||
cache_read_str = f'{usage_metrics.total_cache_read:,}'
|
||||
cache_write_str = f'{usage_metrics.total_cache_write:,}'
|
||||
output_tokens_str = f'{usage_metrics.total_output_tokens:,}'
|
||||
total_tokens_str = (
|
||||
f'{usage_metrics.total_input_tokens + usage_metrics.total_output_tokens:,}'
|
||||
)
|
||||
|
||||
labels_and_values = [
|
||||
(' Total Cost (USD):', cost_str),
|
||||
(' Total Input Tokens:', input_tokens_str),
|
||||
(' Cache Hits:', cache_read_str),
|
||||
(' Cache Writes:', cache_write_str),
|
||||
(' Total Output Tokens:', output_tokens_str),
|
||||
(' Total Tokens:', total_tokens_str),
|
||||
]
|
||||
|
||||
# Calculate max widths for alignment
|
||||
max_label_width = max(len(label) for label, _ in labels_and_values)
|
||||
max_value_width = max(len(value) for _, value in labels_and_values)
|
||||
|
||||
# Construct the summary text with aligned columns
|
||||
summary_lines = [
|
||||
f'{label:<{max_label_width}} {value:>{max_value_width}}'
|
||||
for label, value in labels_and_values
|
||||
]
|
||||
summary_text = '\n'.join(summary_lines)
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=summary_text,
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='Session Summary',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(container)
|
||||
print_formatted_text(HTML(f'\n<grey>Closed session {session_id}</grey>\n'))
|
||||
|
||||
|
||||
def manage_openhands_file(folder_path=None, add_to_trusted=False):
|
||||
openhands_file = Path.home() / '.openhands.toml'
|
||||
default_content: dict = {'trusted_dirs': []}
|
||||
|
||||
if not openhands_file.exists():
|
||||
with open(openhands_file, 'w') as f:
|
||||
toml.dump(default_content, f)
|
||||
|
||||
if folder_path:
|
||||
with open(openhands_file, 'r') as f:
|
||||
try:
|
||||
config = toml.load(f)
|
||||
except Exception:
|
||||
config = default_content
|
||||
|
||||
if 'trusted_dirs' not in config:
|
||||
config['trusted_dirs'] = []
|
||||
|
||||
if folder_path in config['trusted_dirs']:
|
||||
return True
|
||||
|
||||
if add_to_trusted:
|
||||
config['trusted_dirs'].append(folder_path)
|
||||
with open(openhands_file, 'w') as f:
|
||||
toml.dump(config, f)
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_folder_security_agreement(current_dir):
|
||||
is_trusted = manage_openhands_file(current_dir)
|
||||
|
||||
if not is_trusted:
|
||||
security_frame = Frame(
|
||||
TextArea(
|
||||
text=(
|
||||
f'Do you trust the files in this folder?\n\n'
|
||||
f'{current_dir}\n\n'
|
||||
'OpenHands may read and execute files in this folder with your permission.'
|
||||
),
|
||||
style='#ffffff',
|
||||
read_only=True,
|
||||
wrap_lines=True,
|
||||
)
|
||||
)
|
||||
|
||||
clear()
|
||||
print_container(security_frame)
|
||||
|
||||
confirm = cli_confirm('Do you wish to continue?', ['Yes, proceed', 'No, exit'])
|
||||
|
||||
if confirm:
|
||||
manage_openhands_file(current_dir, add_to_trusted=True)
|
||||
|
||||
return confirm
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def main(loop: asyncio.AbstractEventLoop):
|
||||
"""Runs the agent in CLI mode."""
|
||||
|
||||
reload_microagents = False
|
||||
|
||||
args = parse_arguments()
|
||||
|
||||
logger.setLevel(logging.WARNING)
|
||||
@@ -185,6 +649,12 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
|
||||
# Load config from toml and override with command line arguments
|
||||
config: AppConfig = setup_config_from_args(args)
|
||||
|
||||
# TODO: Set working directory from config or use current working directory?
|
||||
current_dir = config.workspace_base
|
||||
|
||||
if not current_dir:
|
||||
raise ValueError('Workspace base directory not specified')
|
||||
|
||||
# Read task from file, CLI args, or stdin
|
||||
task_str = read_task(args, config.cli_multiline_input)
|
||||
|
||||
@@ -192,10 +662,19 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
|
||||
initial_user_action = MessageAction(content=task_str) if task_str else None
|
||||
|
||||
sid = str(uuid4())
|
||||
display_message(f'Session ID: {sid}')
|
||||
is_loaded = asyncio.Event()
|
||||
|
||||
# Show OpenHands banner and session ID
|
||||
display_banner(session_id=sid, is_loaded=is_loaded)
|
||||
|
||||
# Show Initialization loader
|
||||
loop.run_in_executor(
|
||||
None, display_initialization_animation, 'Initializing...', is_loaded
|
||||
)
|
||||
|
||||
agent = create_agent(config)
|
||||
|
||||
mcp_tools = await fetch_mcp_tools_from_config(config.mcp)
|
||||
agent.set_mcp_tools(mcp_tools)
|
||||
runtime = create_runtime(
|
||||
config,
|
||||
sid=sid,
|
||||
@@ -207,26 +686,64 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
|
||||
async def prompt_for_next_task() -> None:
|
||||
next_message = await read_prompt_input(config.cli_multiline_input)
|
||||
if not next_message.strip():
|
||||
await prompt_for_next_task()
|
||||
if next_message == 'exit':
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.STOPPED), EventSource.ENVIRONMENT
|
||||
)
|
||||
usage_metrics = UsageMetrics()
|
||||
|
||||
async def prompt_for_next_task():
|
||||
nonlocal reload_microagents
|
||||
while True:
|
||||
next_message = await read_prompt_input(config.cli_multiline_input)
|
||||
|
||||
if not next_message.strip():
|
||||
continue
|
||||
|
||||
if next_message == '/exit':
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.STOPPED), EventSource.ENVIRONMENT
|
||||
)
|
||||
shutdown(usage_metrics, sid)
|
||||
return
|
||||
elif next_message == '/help':
|
||||
display_help()
|
||||
continue
|
||||
elif next_message == '/init':
|
||||
if config.runtime == 'local':
|
||||
init_repo = await init_repository(current_dir)
|
||||
if init_repo:
|
||||
event_stream.add_event(
|
||||
MessageAction(content=REPO_MD_CREATE_PROMPT),
|
||||
EventSource.USER,
|
||||
)
|
||||
reload_microagents = True
|
||||
return
|
||||
else:
|
||||
print_formatted_text(
|
||||
'\nRepository initialization through the CLI is only supported for local runtime.\n'
|
||||
)
|
||||
continue
|
||||
|
||||
action = MessageAction(content=next_message)
|
||||
event_stream.add_event(action, EventSource.USER)
|
||||
return
|
||||
action = MessageAction(content=next_message)
|
||||
event_stream.add_event(action, EventSource.USER)
|
||||
|
||||
async def on_event_async(event: Event) -> None:
|
||||
nonlocal reload_microagents
|
||||
display_event(event, config)
|
||||
update_usage_metrics(event, usage_metrics)
|
||||
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state in [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
]:
|
||||
# Reload microagents after initialization of repo.md
|
||||
if reload_microagents:
|
||||
microagents: list[BaseMicroagent] = (
|
||||
runtime.get_microagents_from_selected_repo(None)
|
||||
)
|
||||
memory.load_user_workspace_microagents(microagents)
|
||||
reload_microagents = False
|
||||
await prompt_for_next_task()
|
||||
|
||||
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
|
||||
user_confirmed = await read_confirmation_input()
|
||||
if user_confirmed:
|
||||
@@ -264,6 +781,25 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
|
||||
repo_directory=repo_directory,
|
||||
)
|
||||
|
||||
# Clear loading animation
|
||||
is_loaded.set()
|
||||
|
||||
if not check_folder_security_agreement(current_dir):
|
||||
# User rejected, exit application
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.STOPPED), EventSource.ENVIRONMENT
|
||||
)
|
||||
return
|
||||
|
||||
# Clear the terminal
|
||||
clear()
|
||||
|
||||
# Show OpenHands banner and session ID
|
||||
display_banner(session_id=sid, is_loaded=is_loaded)
|
||||
|
||||
# Show OpenHands welcome
|
||||
display_welcome_message()
|
||||
|
||||
if initial_user_action:
|
||||
# If there's an initial user action, enqueue it and do not prompt again
|
||||
event_stream.add_event(initial_user_action, EventSource.USER)
|
||||
|
||||
@@ -10,10 +10,9 @@ class AgentConfig(BaseModel):
|
||||
"""Configuration for the agent.
|
||||
|
||||
Attributes:
|
||||
function_calling: Whether function calling is enabled. Default is True.
|
||||
codeact_enable_browsing: Whether browsing delegate is enabled in the action space. Default is False. Only works with function calling.
|
||||
codeact_enable_llm_editor: Whether LLM editor is enabled in the action space. Default is False. Only works with function calling.
|
||||
codeact_enable_jupyter: Whether Jupyter is enabled in the action space. Default is False.
|
||||
enable_browsing: Whether browsing delegate is enabled in the action space. Default is False. Only works with function calling.
|
||||
enable_llm_editor: Whether LLM editor is enabled in the action space. Default is False. Only works with function calling.
|
||||
enable_jupyter: Whether Jupyter is enabled in the action space. Default is False.
|
||||
llm_config: The name of the llm config to use. If specified, this will override global llm config.
|
||||
enable_prompt_extensions: Whether to use prompt extensions (e.g., microagents, inject runtime info). Default is True.
|
||||
disabled_microagents: A list of microagents to disable (by name, without .py extension, e.g. ["github", "lint"]). Default is None.
|
||||
@@ -23,9 +22,9 @@ class AgentConfig(BaseModel):
|
||||
"""
|
||||
|
||||
llm_config: str | None = Field(default=None)
|
||||
codeact_enable_browsing: bool = Field(default=True)
|
||||
codeact_enable_llm_editor: bool = Field(default=False)
|
||||
codeact_enable_jupyter: bool = Field(default=True)
|
||||
enable_browsing: bool = Field(default=True)
|
||||
enable_llm_editor: bool = Field(default=False)
|
||||
enable_jupyter: bool = Field(default=True)
|
||||
enable_prompt_extensions: bool = Field(default=True)
|
||||
disabled_microagents: list[str] = Field(default_factory=list)
|
||||
enable_history_truncation: bool = Field(default=True)
|
||||
|
||||
@@ -11,6 +11,7 @@ from openhands.core.config.config_utils import (
|
||||
)
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
|
||||
@@ -47,6 +48,7 @@ class AppConfig(BaseModel):
|
||||
file_uploads_allowed_extensions: Allowed file extensions. `['.*']` allows all.
|
||||
cli_multiline_input: Whether to enable multiline input in CLI. When disabled,
|
||||
input is read line by line. When enabled, input continues until /exit command.
|
||||
mcp: MCP configuration settings.
|
||||
"""
|
||||
|
||||
llms: dict[str, LLMConfig] = Field(default_factory=dict)
|
||||
@@ -88,6 +90,7 @@ class AppConfig(BaseModel):
|
||||
max_concurrent_conversations: int = Field(
|
||||
default=3
|
||||
) # Maximum number of concurrent agent loops allowed per user
|
||||
mcp: MCPConfig = Field(default_factory=MCPConfig)
|
||||
|
||||
defaults_dict: ClassVar[dict] = {}
|
||||
|
||||
|
||||
68
openhands/core/config/mcp_config.py
Normal file
68
openhands/core/config/mcp_config.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
|
||||
class MCPSSEConfig(BaseModel):
|
||||
"""Configuration for MCP SSE (Server-Sent Events) settings.
|
||||
|
||||
Attributes:
|
||||
mcp_servers: List of MCP server URLs.
|
||||
"""
|
||||
|
||||
mcp_servers: List[str] = Field(default_factory=list)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
def validate_servers(self) -> None:
|
||||
"""Validate that server URLs are valid and unique."""
|
||||
# Check for duplicate server URLs
|
||||
if len(set(self.mcp_servers)) != len(self.mcp_servers):
|
||||
raise ValueError('Duplicate MCP server URLs are not allowed')
|
||||
|
||||
# Validate URLs
|
||||
for url in self.mcp_servers:
|
||||
try:
|
||||
result = urlparse(url)
|
||||
if not all([result.scheme, result.netloc]):
|
||||
raise ValueError(f'Invalid URL format: {url}')
|
||||
except Exception as e:
|
||||
raise ValueError(f'Invalid URL {url}: {str(e)}')
|
||||
|
||||
|
||||
class MCPConfig(BaseModel):
|
||||
"""Configuration for MCP (Message Control Protocol) settings.
|
||||
|
||||
Attributes:
|
||||
sse: SSE-specific configuration.
|
||||
"""
|
||||
|
||||
sse: MCPSSEConfig = Field(default_factory=MCPSSEConfig)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
@classmethod
|
||||
def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
|
||||
"""
|
||||
Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
|
||||
|
||||
The configuration is built from all keys in data.
|
||||
|
||||
Returns:
|
||||
dict[str, MCPConfig]: A mapping where the key "mcp" corresponds to the [mcp] configuration
|
||||
"""
|
||||
# Initialize the result mapping
|
||||
mcp_mapping: dict[str, MCPConfig] = {}
|
||||
|
||||
try:
|
||||
# Create SSE config if present
|
||||
sse_config = MCPSSEConfig.model_validate(data)
|
||||
sse_config.validate_servers()
|
||||
|
||||
# Create the main MCP config
|
||||
mcp_mapping['mcp'] = cls(sse=sse_config)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f'Invalid MCP configuration: {e}')
|
||||
|
||||
return mcp_mapping
|
||||
@@ -23,6 +23,7 @@ from openhands.core.config.config_utils import (
|
||||
)
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
from openhands.storage import get_file_store
|
||||
@@ -202,6 +203,21 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
|
||||
# Re-raise ValueError from SandboxConfig.from_toml_section
|
||||
raise ValueError('Error in [sandbox] section in config.toml')
|
||||
|
||||
# Process MCP sections if present
|
||||
if 'mcp' in toml_config:
|
||||
try:
|
||||
mcp_mapping = MCPConfig.from_toml_section(toml_config['mcp'])
|
||||
# We only use the base mcp config for now
|
||||
if 'mcp' in mcp_mapping:
|
||||
cfg.mcp = mcp_mapping['mcp']
|
||||
except (TypeError, KeyError, ValidationError) as e:
|
||||
logger.openhands_logger.warning(
|
||||
f'Cannot parse MCP config from toml, values have not been applied.\nError: {e}'
|
||||
)
|
||||
except ValueError:
|
||||
# Re-raise ValueError from MCPConfig.from_toml_section
|
||||
raise ValueError('Error in MCP sections in config.toml')
|
||||
|
||||
# Process condenser section if present
|
||||
if 'condenser' in toml_config:
|
||||
try:
|
||||
@@ -259,6 +275,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
|
||||
'security',
|
||||
'sandbox',
|
||||
'condenser',
|
||||
'mcp',
|
||||
}
|
||||
for key in toml_config:
|
||||
if key.lower() not in known_sections:
|
||||
|
||||
@@ -222,14 +222,14 @@ class BrowserUnavailableException(Exception):
|
||||
# ============================================
|
||||
|
||||
|
||||
class MicroAgentError(Exception):
|
||||
class MicroagentError(Exception):
|
||||
"""Base exception for all microagent errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MicroAgentValidationError(MicroAgentError):
|
||||
class MicroagentValidationError(MicroagentError):
|
||||
"""Raised when there's a validation error in microagent metadata."""
|
||||
|
||||
def __init__(self, message: str = 'Micro agent validation failed') -> None:
|
||||
def __init__(self, message: str = 'Microagent validation failed') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
@@ -30,6 +30,7 @@ from openhands.events.action.action import Action
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.io import read_input, read_task
|
||||
from openhands.mcp import fetch_mcp_tools_from_config
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
@@ -95,6 +96,8 @@ async def run_controller(
|
||||
|
||||
if agent is None:
|
||||
agent = create_agent(config)
|
||||
mcp_tools = await fetch_mcp_tools_from_config(config.mcp)
|
||||
agent.set_mcp_tools(mcp_tools)
|
||||
|
||||
# when the runtime is created, it will be connected and clone the selected repository
|
||||
repo_directory = None
|
||||
|
||||
@@ -38,6 +38,10 @@ class ActionType(str, Enum):
|
||||
"""Interact with the browser instance.
|
||||
"""
|
||||
|
||||
MCP = 'call_tool_mcp'
|
||||
"""Interact with the MCP server.
|
||||
"""
|
||||
|
||||
DELEGATE = 'delegate'
|
||||
"""Delegates a task to another agent.
|
||||
"""
|
||||
|
||||
@@ -49,3 +49,6 @@ class ObservationType(str, Enum):
|
||||
|
||||
RECALL = 'recall'
|
||||
"""Result of a recall operation. This can be the workspace context, a microagent, or other types of information."""
|
||||
|
||||
MCP = 'mcp'
|
||||
"""Result of a MCP Server operation"""
|
||||
|
||||
@@ -18,7 +18,7 @@ from openhands.events.event import Event
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.microagent.microagent import BaseMicroAgent
|
||||
from openhands.microagent.microagent import BaseMicroagent
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.security import SecurityAnalyzer, options
|
||||
@@ -160,7 +160,7 @@ def create_memory(
|
||||
memory.set_runtime_info(runtime)
|
||||
|
||||
# loads microagents from repo/.openhands/microagents
|
||||
microagents: list[BaseMicroAgent] = runtime.get_microagents_from_selected_repo(
|
||||
microagents: list[BaseMicroagent] = runtime.get_microagents_from_selected_repo(
|
||||
selected_repository
|
||||
)
|
||||
memory.load_user_workspace_microagents(microagents)
|
||||
@@ -175,6 +175,7 @@ def create_agent(config: AppConfig) -> Agent:
|
||||
agent_cls: Type[Agent] = Agent.get_cls(config.default_agent)
|
||||
agent_config = config.get_agent_config(config.default_agent)
|
||||
llm_config = config.get_llm_config_from_agent(config.default_agent)
|
||||
|
||||
agent = agent_cls(
|
||||
llm=LLM(config=llm_config),
|
||||
config=agent_config,
|
||||
|
||||
@@ -15,6 +15,7 @@ from openhands.events.action.files import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.action.mcp import McpAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
|
||||
__all__ = [
|
||||
@@ -35,4 +36,5 @@ __all__ = [
|
||||
'ActionConfirmationStatus',
|
||||
'AgentThinkAction',
|
||||
'RecallAction',
|
||||
'McpAction',
|
||||
]
|
||||
|
||||
32
openhands/events/action/mcp.py
Normal file
32
openhands/events/action/mcp.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
|
||||
|
||||
@dataclass
|
||||
class McpAction(Action):
|
||||
name: str
|
||||
arguments: str | None = None
|
||||
thought: str = ''
|
||||
action: str = ActionType.MCP
|
||||
runnable: ClassVar[bool] = True
|
||||
security_risk: ActionSecurityRisk | None = None
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return (
|
||||
f'I am interacting with the MCP server with name:\n'
|
||||
f'```\n{self.name}\n```\n'
|
||||
f'and arguments:\n'
|
||||
f'```\n{self.arguments}\n```'
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
ret = '**McpAction**\n'
|
||||
if self.thought:
|
||||
ret += f'THOUGHT: {self.thought}\n'
|
||||
ret += f'NAME: {self.name}\n'
|
||||
ret += f'ARGUMENTS: {self.arguments}'
|
||||
return ret
|
||||
@@ -44,4 +44,5 @@ __all__ = [
|
||||
'AgentCondensationObservation',
|
||||
'RecallObservation',
|
||||
'RecallType',
|
||||
'MCPObservation',
|
||||
]
|
||||
|
||||
15
openhands/events/observation/mcp.py
Normal file
15
openhands/events/observation/mcp.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.core.schema import ObservationType
|
||||
from openhands.events.observation.observation import Observation
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPObservation(Observation):
|
||||
"""This data class represents the result of a MCP Server operation."""
|
||||
|
||||
observation: str = ObservationType.MCP
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.content
|
||||
@@ -22,6 +22,7 @@ from openhands.events.action.files import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.action.mcp import McpAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
|
||||
actions = (
|
||||
@@ -41,6 +42,7 @@ actions = (
|
||||
ChangeAgentStateAction,
|
||||
MessageAction,
|
||||
CondensationAction,
|
||||
McpAction,
|
||||
)
|
||||
|
||||
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import Event, EventSource
|
||||
from openhands.events.serialization.action import action_from_dict
|
||||
from openhands.events.serialization.observation import observation_from_dict
|
||||
@@ -134,11 +135,12 @@ def event_to_dict(event: 'Event') -> dict:
|
||||
k: (v.value if isinstance(v, Enum) else _convert_pydantic_to_dict(v))
|
||||
for k, v in props.items()
|
||||
}
|
||||
logger.debug(f'extras data in event_to_dict: {d["extras"]}')
|
||||
# Include success field for CmdOutputObservation
|
||||
if hasattr(event, 'success'):
|
||||
d['success'] = event.success
|
||||
else:
|
||||
raise ValueError('Event must be either action or observation')
|
||||
raise ValueError(f'Event must be either action or observation. has: {event}')
|
||||
return d
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from openhands.events.observation.files import (
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
)
|
||||
from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.events.observation.reject import UserRejectObservation
|
||||
from openhands.events.observation.success import SuccessObservation
|
||||
@@ -45,6 +46,7 @@ observations = (
|
||||
AgentCondensationObservation,
|
||||
AgentThinkObservation,
|
||||
RecallObservation,
|
||||
MCPObservation,
|
||||
)
|
||||
|
||||
OBSERVATION_TYPE_TO_CLASS = {
|
||||
|
||||
@@ -160,29 +160,38 @@ class EventStream(EventStore):
|
||||
raise ValueError(
|
||||
f'Event already has an ID:{event.id}. It was probably added back to the EventStream from inside a handler, triggering a loop.'
|
||||
)
|
||||
event._timestamp = datetime.now().isoformat()
|
||||
event._source = source # type: ignore [attr-defined]
|
||||
with self._lock:
|
||||
event._id = self.cur_id # type: ignore [attr-defined]
|
||||
self.cur_id += 1
|
||||
logger.debug(f'Adding {type(event).__name__} id={event.id} from {source.name}')
|
||||
event._timestamp = datetime.now().isoformat()
|
||||
event._source = source # type: ignore [attr-defined]
|
||||
data = event_to_dict(event)
|
||||
data = self._replace_secrets(data)
|
||||
event = event_from_dict(data)
|
||||
|
||||
# Take a copy of the current write page
|
||||
current_write_page = self._write_page_cache
|
||||
|
||||
data = event_to_dict(event)
|
||||
data = self._replace_secrets(data)
|
||||
event = event_from_dict(data)
|
||||
current_write_page.append(data)
|
||||
|
||||
# If the page is full, create a new page for future events / other threads to use
|
||||
if len(current_write_page) == self.cache_size:
|
||||
self._write_page_cache = []
|
||||
|
||||
if event.id is not None:
|
||||
# Write the event to the store - this can take some time
|
||||
self.file_store.write(
|
||||
self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
|
||||
)
|
||||
self._write_page_cache.append(data)
|
||||
self._store_cache_page()
|
||||
|
||||
# Store the cache page last - if it is not present during reads then it will simply be bypassed.
|
||||
self._store_cache_page(current_write_page)
|
||||
self._queue.put(event)
|
||||
|
||||
def _store_cache_page(self):
|
||||
def _store_cache_page(self, current_write_page: list[dict]):
|
||||
"""Store a page in the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
|
||||
current_write_page = self._write_page_cache
|
||||
if len(current_write_page) < self.cache_size:
|
||||
return
|
||||
self._write_page_cache = []
|
||||
start = current_write_page[0]['id']
|
||||
end = start + self.cache_size
|
||||
contents = json.dumps(current_write_page)
|
||||
|
||||
@@ -36,7 +36,15 @@ def dumps(obj, **kwargs):
|
||||
"""Serialize an object to str format"""
|
||||
if not kwargs:
|
||||
return _json_encoder.encode(obj)
|
||||
return json.dumps(obj, cls=OpenHandsJSONEncoder, **kwargs)
|
||||
|
||||
# Create a copy of the kwargs to avoid modifying the original
|
||||
encoder_kwargs = kwargs.copy()
|
||||
|
||||
# If cls is specified, use it; otherwise use our custom encoder
|
||||
if 'cls' not in encoder_kwargs:
|
||||
encoder_kwargs['cls'] = OpenHandsJSONEncoder
|
||||
|
||||
return json.dumps(obj, **encoder_kwargs)
|
||||
|
||||
|
||||
def loads(json_str, **kwargs):
|
||||
|
||||
@@ -375,12 +375,17 @@ class LLM(RetryMixin, DebugMixin):
|
||||
if self.config.model.startswith('litellm_proxy/'):
|
||||
# IF we are using LiteLLM proxy, get model info from LiteLLM proxy
|
||||
# GET {base_url}/v1/model/info with litellm_model_id as path param
|
||||
base_url = self.config.base_url.strip() if self.config.base_url else ''
|
||||
if not base_url.startswith(('http://', 'https://')):
|
||||
base_url = 'http://' + base_url
|
||||
|
||||
response = httpx.get(
|
||||
f'{self.config.base_url}/v1/model/info',
|
||||
f'{base_url}/v1/model/info',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.config.api_key.get_secret_value() if self.config.api_key else None}'
|
||||
},
|
||||
)
|
||||
|
||||
resp_json = response.json()
|
||||
if 'data' not in resp_json:
|
||||
logger.error(
|
||||
|
||||
21
openhands/mcp/__init__.py
Normal file
21
openhands/mcp/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from openhands.mcp.client import MCPClient
|
||||
from openhands.mcp.tool import (
|
||||
BaseTool,
|
||||
MCPClientTool,
|
||||
)
|
||||
from openhands.mcp.utils import (
|
||||
call_tool_mcp,
|
||||
convert_mcp_clients_to_tools,
|
||||
create_mcp_clients,
|
||||
fetch_mcp_tools_from_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'MCPClient',
|
||||
'convert_mcp_clients_to_tools',
|
||||
'create_mcp_clients',
|
||||
'BaseTool',
|
||||
'MCPClientTool',
|
||||
'fetch_mcp_tools_from_config',
|
||||
'call_tool_mcp',
|
||||
]
|
||||
98
openhands/mcp/client.py
Normal file
98
openhands/mcp/client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.mcp.tool import BaseTool, MCPClientTool
|
||||
|
||||
|
||||
class MCPClient(BaseModel):
|
||||
"""
|
||||
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
|
||||
"""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
exit_stack: AsyncExitStack = AsyncExitStack()
|
||||
description: str = 'MCP client tools for server interaction'
|
||||
tools: List[BaseTool] = Field(default_factory=list)
|
||||
tool_map: Dict[str, BaseTool] = Field(default_factory=dict)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
async def connect_sse(self, server_url: str, timeout: float = 30.0) -> None:
|
||||
"""Connect to an MCP server using SSE transport.
|
||||
|
||||
Args:
|
||||
server_url: The URL of the SSE server to connect to.
|
||||
timeout: Connection timeout in seconds. Default is 30 seconds.
|
||||
"""
|
||||
if not server_url:
|
||||
raise ValueError('Server URL is required.')
|
||||
if self.session:
|
||||
await self.disconnect()
|
||||
|
||||
try:
|
||||
streams_context = sse_client(
|
||||
url=server_url,
|
||||
)
|
||||
streams = await self.exit_stack.enter_async_context(streams_context)
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(*streams)
|
||||
)
|
||||
|
||||
await self._initialize_and_list_tools()
|
||||
except Exception as e:
|
||||
logger.error(f'Error connecting to {server_url}: {str(e)}')
|
||||
raise
|
||||
|
||||
async def _initialize_and_list_tools(self) -> None:
|
||||
"""Initialize session and populate tool map."""
|
||||
if not self.session:
|
||||
raise RuntimeError('Session not initialized.')
|
||||
|
||||
await self.session.initialize()
|
||||
response = await self.session.list_tools()
|
||||
|
||||
# Clear existing tools
|
||||
self.tools = []
|
||||
|
||||
# Create proper tool objects for each server tool
|
||||
for tool in response.tools:
|
||||
server_tool = MCPClientTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
inputSchema=tool.inputSchema,
|
||||
session=self.session,
|
||||
)
|
||||
self.tool_map[tool.name] = server_tool
|
||||
self.tools.append(server_tool)
|
||||
|
||||
logger.info(
|
||||
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
|
||||
)
|
||||
|
||||
async def call_tool(self, tool_name: str, args: Dict):
|
||||
"""Call a tool on the MCP server."""
|
||||
if tool_name not in self.tool_map:
|
||||
raise ValueError(f'Tool {tool_name} not found.')
|
||||
return await self.tool_map[tool_name].execute(**args)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the MCP server and clean up resources."""
|
||||
if self.session:
|
||||
try:
|
||||
# Close the session first
|
||||
if hasattr(self.session, 'close'):
|
||||
await self.session.close()
|
||||
# Then close the exit stack
|
||||
await self.exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.error(f'Error during disconnect: {str(e)}')
|
||||
finally:
|
||||
self.session = None
|
||||
self.tools = []
|
||||
logger.info('Disconnected from MCP server')
|
||||
54
openhands/mcp/tool.py
Normal file
54
openhands/mcp/tool.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.types import CallToolResult, TextContent, Tool
|
||||
|
||||
|
||||
class BaseTool(ABC, Tool):
|
||||
@classmethod
|
||||
def postfix(cls) -> str:
|
||||
return '_mcp_tool_call'
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs) -> CallToolResult:
|
||||
"""Execute the tool with given parameters."""
|
||||
|
||||
def to_param(self) -> Dict:
|
||||
"""Convert tool to function call format."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': self.name + self.postfix(),
|
||||
'description': self.description,
|
||||
'parameters': self.inputSchema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class MCPClientTool(BaseTool):
|
||||
"""Represents a tool proxy that can be called on the MCP server from the client side."""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
|
||||
async def execute(self, **kwargs) -> CallToolResult:
|
||||
"""Execute the tool by making a remote call to the MCP server."""
|
||||
if not self.session:
|
||||
return CallToolResult(
|
||||
content=[TextContent(text='Not connected to MCP server', type='text')],
|
||||
isError=True,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self.session.call_tool(self.name, kwargs)
|
||||
return result
|
||||
except Exception as e:
|
||||
return CallToolResult(
|
||||
content=[
|
||||
TextContent(text=f'Error executing tool: {str(e)}', type='text')
|
||||
],
|
||||
isError=True,
|
||||
)
|
||||
135
openhands/mcp/utils.py
Normal file
135
openhands/mcp/utils.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.mcp import McpAction
|
||||
from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.mcp.client import MCPClient
|
||||
|
||||
|
||||
def convert_mcp_clients_to_tools(mcp_clients: list[MCPClient] | None) -> list[dict]:
|
||||
"""
|
||||
Converts a list of MCPClient instances to ChatCompletionToolParam format
|
||||
that can be used by CodeActAgent.
|
||||
|
||||
Args:
|
||||
mcp_clients: List of MCPClient instances or None
|
||||
|
||||
Returns:
|
||||
List of dicts of tools ready to be used by CodeActAgent
|
||||
"""
|
||||
if mcp_clients is None:
|
||||
logger.warning('mcp_clients is None, returning empty list')
|
||||
return []
|
||||
|
||||
all_mcp_tools = []
|
||||
try:
|
||||
for client in mcp_clients:
|
||||
# Each MCPClient has an mcp_clients property that is a ToolCollection
|
||||
# The ToolCollection has a to_params method that converts tools to ChatCompletionToolParam format
|
||||
for tool in client.tools:
|
||||
mcp_tools = tool.to_param()
|
||||
all_mcp_tools.append(mcp_tools)
|
||||
except Exception as e:
|
||||
logger.error(f'Error in convert_mcp_clients_to_tools: {e}')
|
||||
return []
|
||||
return all_mcp_tools
|
||||
|
||||
|
||||
async def create_mcp_clients(
|
||||
sse_mcp_server: list[str],
|
||||
) -> list[MCPClient]:
|
||||
mcp_clients: list[MCPClient] = []
|
||||
# Initialize SSE connections
|
||||
if sse_mcp_server:
|
||||
for server_url in sse_mcp_server:
|
||||
logger.info(
|
||||
f'Initializing MCP agent for {server_url} with SSE connection...'
|
||||
)
|
||||
|
||||
client = MCPClient()
|
||||
try:
|
||||
await client.connect_sse(server_url)
|
||||
# Only add the client to the list after a successful connection
|
||||
mcp_clients.append(client)
|
||||
logger.info(f'Connected to MCP server {server_url} via SSE')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to connect to {server_url}: {str(e)}')
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception as disconnect_error:
|
||||
logger.error(
|
||||
f'Error during disconnect after failed connection: {str(disconnect_error)}'
|
||||
)
|
||||
|
||||
return mcp_clients
|
||||
|
||||
|
||||
async def fetch_mcp_tools_from_config(mcp_config: MCPConfig) -> list[dict]:
|
||||
"""
|
||||
Retrieves the list of MCP tools from the MCP clients.
|
||||
|
||||
Returns:
|
||||
A list of tool dictionaries. Returns an empty list if no connections could be established.
|
||||
"""
|
||||
mcp_clients = []
|
||||
mcp_tools = []
|
||||
try:
|
||||
logger.debug(f'Creating MCP clients with config: {mcp_config}')
|
||||
mcp_clients = await create_mcp_clients(
|
||||
mcp_config.sse.mcp_servers,
|
||||
)
|
||||
|
||||
if not mcp_clients:
|
||||
logger.warning('No MCP clients were successfully connected')
|
||||
return []
|
||||
|
||||
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
|
||||
|
||||
# Always disconnect clients to clean up resources
|
||||
for mcp_client in mcp_clients:
|
||||
try:
|
||||
await mcp_client.disconnect()
|
||||
except Exception as disconnect_error:
|
||||
logger.error(f'Error disconnecting MCP client: {str(disconnect_error)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching MCP tools: {str(e)}')
|
||||
return []
|
||||
|
||||
logger.debug(f'MCP tools: {mcp_tools}')
|
||||
return mcp_tools
|
||||
|
||||
|
||||
async def call_tool_mcp(mcp_clients: list[MCPClient], action: McpAction) -> Observation:
|
||||
"""
|
||||
Call a tool on an MCP server and return the observation.
|
||||
|
||||
Args:
|
||||
action: The MCP action to execute
|
||||
sse_mcp_servers: List of SSE MCP server URLs
|
||||
|
||||
Returns:
|
||||
The observation from the MCP server
|
||||
"""
|
||||
if not mcp_clients:
|
||||
raise ValueError('No MCP clients found')
|
||||
|
||||
logger.debug(f'MCP action received: {action}')
|
||||
# Find the MCP agent that has the matching tool name
|
||||
matching_client = None
|
||||
logger.debug(f'MCP clients: {mcp_clients}')
|
||||
logger.debug(f'MCP action name: {action.name}')
|
||||
for client in mcp_clients:
|
||||
logger.debug(f'MCP client tools: {client.tools}')
|
||||
if action.name in [tool.name for tool in client.tools]:
|
||||
matching_client = client
|
||||
break
|
||||
if matching_client is None:
|
||||
raise ValueError(f'No matching MCP agent found for tool name: {action.name}')
|
||||
logger.debug(f'Matching client: {matching_client}')
|
||||
args_dict = json.loads(action.arguments) if action.arguments else {}
|
||||
response = await matching_client.call_tool(action.name, args_dict)
|
||||
logger.debug(f'MCP response: {response}')
|
||||
|
||||
return MCPObservation(content=f'MCP result:{response.model_dump(mode="json")}')
|
||||
@@ -114,8 +114,14 @@ class LLMAttentionCondenser(RollingCondenser):
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: LLMAttentionCondenserConfig) -> LLMAttentionCondenser:
|
||||
# This condenser cannot take advantage of prompt caching. If it happens
|
||||
# to be set, we'll pay for the cache writes but never get a chance to
|
||||
# save on a read.
|
||||
llm_config = config.llm_config.model_copy()
|
||||
llm_config.caching_prompt = False
|
||||
|
||||
return LLMAttentionCondenser(
|
||||
llm=LLM(config=config.llm_config),
|
||||
llm=LLM(config=llm_config),
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
)
|
||||
|
||||
@@ -155,8 +155,14 @@ CURRENT_STATE: Last flip: Heads, Haiku count: 15/20"""
|
||||
def from_config(
|
||||
cls, config: LLMSummarizingCondenserConfig
|
||||
) -> LLMSummarizingCondenser:
|
||||
# This condenser cannot take advantage of prompt caching. If it happens
|
||||
# to be set, we'll pay for the cache writes but never get a chance to
|
||||
# save on a read.
|
||||
llm_config = config.llm_config.model_copy()
|
||||
llm_config.caching_prompt = False
|
||||
|
||||
return LLMSummarizingCondenser(
|
||||
llm=LLM(config=config.llm_config),
|
||||
llm=LLM(config=llm_config),
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
max_event_length=config.max_event_length,
|
||||
|
||||
@@ -311,8 +311,14 @@ Capture all relevant information, especially:
|
||||
def from_config(
|
||||
cls, config: StructuredSummaryCondenserConfig
|
||||
) -> StructuredSummaryCondenser:
|
||||
# This condenser cannot take advantage of prompt caching. If it happens
|
||||
# to be set, we'll pay for the cache writes but never get a chance to
|
||||
# save on a read.
|
||||
llm_config = config.llm_config.model_copy()
|
||||
llm_config.caching_prompt = False
|
||||
|
||||
return StructuredSummaryCondenser(
|
||||
llm=LLM(config=config.llm_config),
|
||||
llm=LLM(config=llm_config),
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
max_event_length=config.max_event_length,
|
||||
|
||||
@@ -19,6 +19,7 @@ from openhands.events.action import (
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.action.mcp import McpAction
|
||||
from openhands.events.event import Event, RecallType
|
||||
from openhands.events.observation import (
|
||||
AgentCondensationObservation,
|
||||
@@ -36,6 +37,7 @@ from openhands.events.observation.agent import (
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.utils.prompt import PromptManager, RepositoryInfo, RuntimeInfo
|
||||
@@ -167,7 +169,7 @@ class ConversationMemory:
|
||||
- BrowseInteractiveAction: For browsing the web
|
||||
- AgentFinishAction: For ending the interaction
|
||||
- MessageAction: For sending messages
|
||||
|
||||
- McpAction: For interacting with the MCP server
|
||||
pending_tool_call_action_messages: Dictionary mapping response IDs to their corresponding messages.
|
||||
Used in function calling mode to track tool calls that are waiting for their results.
|
||||
|
||||
@@ -193,6 +195,7 @@ class ConversationMemory:
|
||||
FileReadAction,
|
||||
BrowseInteractiveAction,
|
||||
BrowseURLAction,
|
||||
McpAction,
|
||||
),
|
||||
) or (isinstance(action, CmdRunAction) and action.source == 'agent'):
|
||||
tool_metadata = action.tool_call_metadata
|
||||
@@ -326,6 +329,10 @@ class ConversationMemory:
|
||||
else:
|
||||
text = truncate_content(obs.to_agent_observation(), max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, MCPObservation):
|
||||
# logger.warning(f'MCPObservation: {obs}')
|
||||
text = truncate_content(obs.content, max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, IPythonRunCellObservation):
|
||||
text = obs.content
|
||||
# replace base64 images with a placeholder
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user