mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 312993339f | |||
| 6f21b6700a | |||
| af49b615b1 | |||
| 4651edd5b3 | |||
| d7f72fec9c | |||
| 09011c91f8 | |||
| e56fabfc5e | |||
| 56f752557c | |||
| 5f2ad7fbb0 | |||
| 758e30c9a8 | |||
| 28017f232e | |||
| 3302c31c60 | |||
| 116ba199d1 | |||
| 803bdced9c | |||
| 3eecac2003 | |||
| c02e09fc2d | |||
| 18f8661770 | |||
| 04ff4a025b | |||
| 81ef363658 | |||
| 1474c5bc1c | |||
| 9b0a5da839 | |||
| 7ab2ad2c1b | |||
| 8416a019cb | |||
| 73a7c7786d | |||
| 11d12c5a01 | |||
| c4f303a07b | |||
| 3a629cdf08 | |||
| 6ea33b657d | |||
| a526f53181 |
+1
-1
@@ -2,7 +2,7 @@
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
# Frontend code owners
|
||||
/frontend/ @rbren @amanape
|
||||
/frontend/ @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# This script updates the PR description with commands to run the PR locally
|
||||
# It adds both Docker and uvx commands
|
||||
|
||||
# Get the branch name for the PR
|
||||
BRANCH_NAME=$(gh pr view "$PR_NUMBER" --json headRefName --jq .headRefName)
|
||||
|
||||
# Define the Docker command
|
||||
DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${SHORT_SHA}-nikolaik \
|
||||
--name openhands-app-${SHORT_SHA} \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:${SHORT_SHA}"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME} openhands"
|
||||
|
||||
# Get the current PR body
|
||||
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body)
|
||||
|
||||
# Prepare the new PR body with both commands
|
||||
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
|
||||
# For existing PR descriptions, use a more robust approach
|
||||
# Split the PR body at the "To run this PR locally" section and replace everything after it
|
||||
BEFORE_SECTION=$(echo "$PR_BODY" | sed '/To run this PR locally, use the following command:/,$d')
|
||||
NEW_PR_BODY=$(cat <<EOF
|
||||
${BEFORE_SECTION}
|
||||
|
||||
To run this PR locally, use the following command:
|
||||
|
||||
GUI with Docker:
|
||||
\`\`\`
|
||||
${DOCKER_RUN_COMMAND}
|
||||
\`\`\`
|
||||
|
||||
CLI with uvx:
|
||||
\`\`\`
|
||||
${UVX_RUN_COMMAND}
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
else
|
||||
# For new PR descriptions: use heredoc safely without indentation
|
||||
NEW_PR_BODY=$(cat <<EOF
|
||||
$PR_BODY
|
||||
|
||||
---
|
||||
|
||||
To run this PR locally, use the following command:
|
||||
|
||||
GUI with Docker:
|
||||
\`\`\`
|
||||
${DOCKER_RUN_COMMAND}
|
||||
\`\`\`
|
||||
|
||||
CLI with uvx:
|
||||
\`\`\`
|
||||
${UVX_RUN_COMMAND}
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Update the PR description
|
||||
echo "Updating PR description with Docker and uvx commands"
|
||||
gh pr edit "$PR_NUMBER" --body "$NEW_PR_BODY"
|
||||
@@ -332,29 +332,5 @@ jobs:
|
||||
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "updating PR description"
|
||||
DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
--name openhands-app-$SHORT_SHA \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
|
||||
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
|
||||
|
||||
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
|
||||
UPDATED_PR_BODY=$(echo "${PR_BODY}" | sed -E "s|docker run -it --rm.*|$DOCKER_RUN_COMMAND|")
|
||||
else
|
||||
UPDATED_PR_BODY="${PR_BODY}
|
||||
|
||||
---
|
||||
|
||||
To run this PR locally, use the following command:
|
||||
\`\`\`
|
||||
$DOCKER_RUN_COMMAND
|
||||
\`\`\`"
|
||||
fi
|
||||
|
||||
echo "updated body: $UPDATED_PR_BODY"
|
||||
gh pr edit $PR_NUMBER --body "$UPDATED_PR_BODY"
|
||||
echo "Updating PR description with Docker and uvx commands"
|
||||
bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh
|
||||
|
||||
@@ -48,11 +48,11 @@ jobs:
|
||||
- name: Build Environment
|
||||
run: make build
|
||||
- name: Run Unit Tests
|
||||
run: poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
- name: Run E2E Tests
|
||||
run: poetry run pytest -svv tests/e2e
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -svv tests/e2e
|
||||
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
@@ -77,9 +77,11 @@ jobs:
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/unit/test_windows_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
DEBUG: "1"
|
||||
- name: Run Windows runtime tests with LocalRuntime
|
||||
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
TEST_RUNTIME: local
|
||||
DEBUG: "1"
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 30
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
days-before-stale: 40
|
||||
exempt-issue-labels: 'roadmap'
|
||||
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
|
||||
days-before-close: 7
|
||||
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
|
||||
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
|
||||
days-before-close: 10
|
||||
operations-per-run: 150
|
||||
|
||||
+13
-13
@@ -58,34 +58,34 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
|
||||
# Default is 60000, but we've seen up to 200000
|
||||
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
|
||||
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID openhands
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID app
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
usermod -aG openhands openhands && \
|
||||
usermod -aG app openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
RUN chown -R openhands:app /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
USER openhands
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH='/app'
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
# openhands:openhands -> openhands:openhands
|
||||
RUN find /app \! -group openhands -exec chgrp openhands {} +
|
||||
# openhands:openhands -> openhands:app
|
||||
RUN find /app \! -group app -exec chgrp app {} +
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ repos:
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, pydantic, lxml]
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
always_run: true
|
||||
|
||||
@@ -80,7 +80,7 @@ openhands
|
||||
<Note>
|
||||
If you have cloned the repository, you can also run the CLI directly using Poetry:
|
||||
|
||||
poetry run python -m openhands.cli.main
|
||||
poetry run openhands
|
||||
</Note>
|
||||
|
||||
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
|
||||
@@ -7,6 +7,67 @@ description: High level overview of the Graphical User Interface (GUI) in OpenHa
|
||||
|
||||
- [OpenHands is running](/usage/local-setup)
|
||||
|
||||
## Launching the GUI Server
|
||||
|
||||
### Using the CLI Command
|
||||
|
||||
You can launch the OpenHands GUI server directly from the command line using the `serve` command:
|
||||
|
||||
<Callout type="info">
|
||||
**Prerequisites**: You need to have the [OpenHands CLI installed](/usage/how-to/cli-mode) first, OR have `uv` installed and run `uvx --python 3.12 --from openhands-ai openhands serve`. Otherwise, you'll need to use Docker directly (see the [Docker section](#using-docker-directly) below).
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
openhands serve
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Check that Docker is installed and running
|
||||
- Pull the required Docker images
|
||||
- Launch the OpenHands GUI server at http://localhost:3000
|
||||
- Use the same configuration directory (`~/.openhands`) as the CLI mode
|
||||
|
||||
#### Mounting Your Current Directory
|
||||
|
||||
To mount your current working directory into the GUI server container, use the `--mount-cwd` flag:
|
||||
|
||||
```bash
|
||||
openhands serve --mount-cwd
|
||||
```
|
||||
|
||||
This is useful when you want to work on files in your current directory through the GUI. The directory will be mounted at `/workspace` inside the container.
|
||||
|
||||
#### Using GPU Support
|
||||
|
||||
If you have NVIDIA GPUs and want to make them available to the OpenHands container, use the `--gpu` flag:
|
||||
|
||||
```bash
|
||||
openhands serve --gpu
|
||||
```
|
||||
|
||||
This will enable GPU support via nvidia-docker, mounting all available GPUs into the container. You can combine this with other flags:
|
||||
|
||||
```bash
|
||||
openhands serve --gpu --mount-cwd
|
||||
```
|
||||
|
||||
**Prerequisites for GPU support:**
|
||||
- NVIDIA GPU drivers must be installed on your host system
|
||||
- [NVIDIA Container Toolkit (nvidia-docker2)](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) must be installed and configured
|
||||
|
||||
#### Requirements
|
||||
|
||||
Before using the `openhands serve` command, ensure that:
|
||||
- Docker is installed and running on your system
|
||||
- You have internet access to pull the required Docker images
|
||||
- Port 3000 is available on your system
|
||||
|
||||
The CLI will automatically check these requirements and provide helpful error messages if anything is missing.
|
||||
|
||||
### Using Docker Directly
|
||||
|
||||
Alternatively, you can run the GUI server using Docker directly. See the [local setup guide](/usage/local-setup) for detailed Docker instructions.
|
||||
|
||||
## Overview
|
||||
|
||||
### Initial Setup
|
||||
|
||||
@@ -18,7 +18,7 @@ Based on these findings and community feedback, these are the latest models that
|
||||
### Cloud / API-Based Models
|
||||
|
||||
- [anthropic/claude-sonnet-4-20250514](https://www.anthropic.com/api) (recommended)
|
||||
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
|
||||
- [openai/gpt-5-2025-08-07](https://openai.com/api/) (recommended)
|
||||
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
|
||||
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
|
||||
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
|
||||
|
||||
@@ -32,4 +32,4 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
|
||||
|
||||
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
|
||||
|
||||
@@ -66,6 +66,30 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
|
||||
### Start the App
|
||||
|
||||
#### Option 1: Using the CLI Launcher (Recommended)
|
||||
|
||||
If you have Python 3.12+ installed, you can use the CLI launcher for a simpler experience:
|
||||
|
||||
```bash
|
||||
# Install OpenHands
|
||||
pip install openhands-ai
|
||||
|
||||
# Launch the GUI server
|
||||
openhands serve
|
||||
|
||||
# Or with GPU support (requires nvidia-docker)
|
||||
openhands serve --gpu
|
||||
|
||||
# Or with current directory mounted
|
||||
openhands serve --mount-cwd
|
||||
```
|
||||
|
||||
Or using `uvx --python 3.12 --from openhands-ai openhands serve` if you have [uv](https://docs.astral.sh/uv/) installed.
|
||||
|
||||
This will automatically handle Docker requirements checking, image pulling, and launching the GUI server. The `--gpu` flag enables GPU support via nvidia-docker, and `--mount-cwd` mounts your current directory into the container.
|
||||
|
||||
#### Option 2: Using Docker Directly
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -172,7 +172,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--answerer_model', '-a', default='gpt-3.5-turbo', help='answerer model'
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -525,7 +525,7 @@ def commit0_setup(dataset: pd.DataFrame, repo_split: str) -> pd.DataFrame:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -10,7 +10,6 @@ import huggingface_hub
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
from PIL import Image
|
||||
from pydantic import SecretStr
|
||||
|
||||
from evaluation.benchmarks.gaia.scorer import question_scorer
|
||||
from evaluation.benchmarks.gaia.utils import (
|
||||
@@ -31,8 +30,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_from_toml,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
@@ -80,8 +79,7 @@ def get_config(
|
||||
|
||||
config_copy = copy.deepcopy(config)
|
||||
load_from_toml(config_copy)
|
||||
if config_copy.search_api_key:
|
||||
config.search_api_key = SecretStr(config_copy.search_api_key)
|
||||
config.search_api_key = config_copy.search_api_key
|
||||
return config
|
||||
|
||||
|
||||
@@ -294,7 +292,7 @@ Here is the task:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--level',
|
||||
type=str,
|
||||
|
||||
@@ -20,8 +20,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -134,7 +134,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--hubs',
|
||||
type=str,
|
||||
|
||||
@@ -38,8 +38,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -312,7 +312,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
# data split must be one of 'gpqa_main', 'gqpa_diamond', 'gpqa_experts', 'gpqa_extended'
|
||||
parser.add_argument(
|
||||
'--data-split',
|
||||
|
||||
@@ -21,7 +21,7 @@ from evaluation.utils.shared import (
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_parser,
|
||||
get_evaluation_parser,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -167,7 +167,7 @@ def process_predictions(predictions_path: str):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--eval-split',
|
||||
|
||||
@@ -30,8 +30,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -358,7 +358,7 @@ Be thorough in your exploration, testing, and reasoning. It's fine if your think
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--eval-split',
|
||||
|
||||
@@ -18,8 +18,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -267,7 +267,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -23,8 +23,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -229,7 +229,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
|
||||
SUBSETS = [
|
||||
# Eurus subset: https://arxiv.org/abs/2404.02078
|
||||
|
||||
@@ -4,7 +4,11 @@ import pprint
|
||||
|
||||
import tqdm
|
||||
|
||||
from openhands.core.config import get_llm_config_arg, get_parser, load_openhands_config
|
||||
from openhands.core.config import (
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
@@ -111,7 +115,7 @@ def classify_error(llm: LLM, failed_case: dict) -> str:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--json_file_path',
|
||||
type=str,
|
||||
|
||||
@@ -34,8 +34,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -273,7 +273,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--eval-split',
|
||||
|
||||
@@ -30,7 +30,7 @@ from evaluation.utils.shared import (
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_parser,
|
||||
get_evaluation_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime
|
||||
@@ -323,7 +323,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--input-file',
|
||||
type=str,
|
||||
|
||||
@@ -32,8 +32,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -772,7 +772,7 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
|
||||
if __name__ == '__main__':
|
||||
# pdb.set_trace()
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -21,8 +21,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -239,7 +239,7 @@ If the program uses some packages that are incompatible, please figure out alter
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--use-knowledge',
|
||||
type=str,
|
||||
|
||||
@@ -183,24 +183,7 @@ The final results will be saved to `evaluation/evaluation_outputs/outputs/swe_be
|
||||
- `report.json`: a JSON file that contains keys like `"resolved_ids"` pointing to instance IDs that are resolved by the agent.
|
||||
- `logs/`: a directory of test logs
|
||||
|
||||
### Run evaluation with `RemoteRuntime`
|
||||
|
||||
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to run rollout in parallel in the cloud, so you don't need a powerful machine to run evaluation.
|
||||
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
|
||||
|
||||
```bash
|
||||
./evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh [output.jsonl filepath] [num_workers]
|
||||
|
||||
# Example - This evaluates patches generated by CodeActAgent on Llama-3.1-70B-Instruct-Turbo on "princeton-nlp/SWE-bench_Lite"'s test set, with 16 number of workers running in parallel
|
||||
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
|
||||
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_100_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
|
||||
```
|
||||
|
||||
To clean-up all existing runtimes that you've already started, run:
|
||||
|
||||
```bash
|
||||
ALLHANDS_API_KEY="YOUR-API-KEY" ./evaluation/utils/scripts/cleanup_remote_runtime.sh
|
||||
```
|
||||
|
||||
## SWT-Bench Evaluation
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from evaluation.utils.shared import (
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_parser,
|
||||
get_evaluation_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime
|
||||
@@ -353,7 +353,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--input-file',
|
||||
type=str,
|
||||
|
||||
@@ -43,8 +43,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
@@ -732,7 +732,7 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -28,8 +28,8 @@ from evaluation.utils.shared import (
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
@@ -201,7 +201,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -31,8 +31,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -644,7 +644,7 @@ SWEGYM_EXCLUDE_IDS = [
|
||||
]
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
INPUT_FILE=$1
|
||||
NUM_WORKERS=$2
|
||||
DATASET=$3
|
||||
SPLIT=$4
|
||||
|
||||
if [ -z "$INPUT_FILE" ]; then
|
||||
echo "INPUT_FILE not specified (should be a path to a jsonl file)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$DATASET" ]; then
|
||||
echo "DATASET not specified, use default princeton-nlp/SWE-bench_Lite"
|
||||
DATASET="princeton-nlp/SWE-bench_Lite"
|
||||
fi
|
||||
|
||||
if [ -z "$SPLIT" ]; then
|
||||
echo "SPLIT not specified, use default test"
|
||||
SPLIT="test"
|
||||
fi
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
echo "NUM_WORKERS not specified, use default 1"
|
||||
NUM_WORKERS=1
|
||||
fi
|
||||
|
||||
echo "... Evaluating on $INPUT_FILE ..."
|
||||
|
||||
COMMAND="poetry run python evaluation/benchmarks/swe_bench/eval_infer.py \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--input-file $INPUT_FILE \
|
||||
--dataset $DATASET \
|
||||
--split $SPLIT"
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
# update the output with evaluation results
|
||||
poetry run python evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py $INPUT_FILE
|
||||
@@ -5,8 +5,7 @@ pynguin_ids = ['pydata__xarray-6548-16541', 'pydata__xarray-7003-16557', 'pydata
|
||||
ids = ['pydata__xarray-3114-16452', 'pydata__xarray-3151-16453', 'pydata__xarray-3156-16454', 'pydata__xarray-3239-16456', 'pydata__xarray-3239-16457', 'pydata__xarray-3239-16458', 'pydata__xarray-3302-16459', 'pydata__xarray-3364-16461', 'pydata__xarray-3677-16471', 'pydata__xarray-3905-16478', 'pydata__xarray-4182-16484', 'pydata__xarray-4248-16486', 'pydata__xarray-4339-16487', 'pydata__xarray-4419-16488', 'pydata__xarray-4629-16492', 'pydata__xarray-4750-16496', 'pydata__xarray-4802-16505', 'pydata__xarray-4966-16515', 'pydata__xarray-4994-16516', 'pydata__xarray-5033-16517', 'pydata__xarray-5126-16518', 'pydata__xarray-5126-16519', 'pydata__xarray-5131-16520', 'pydata__xarray-5365-16529', 'pydata__xarray-5455-16530', 'pydata__xarray-5662-16532', 'pydata__xarray-5731-16534', 'pydata__xarray-6135-16535', 'pydata__xarray-6135-16536', 'pydata__xarray-6386-16537', 'pydata__xarray-6394-16538', 'pydata__xarray-6400-16539', 'pydata__xarray-6461-16540', 'pydata__xarray-6548-16541', 'pydata__xarray-6599-16543', 'pydata__xarray-6601-16544', 'pydata__xarray-6882-16548', 'pydata__xarray-6889-16549', 'pydata__xarray-7003-16557', 'pydata__xarray-7147-16571', 'pydata__xarray-7150-16572', 'pydata__xarray-7203-16577', 'pydata__xarray-7229-16578', 'pydata__xarray-7393-16581', 'pydata__xarray-7400-16582']
|
||||
|
||||
|
||||
Command eval (our approach):
|
||||
poetry run ./evaluation/benchmarks/testgeneval/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/kjain14__testgeneval-test/CodeActAgent/gpt-4o_maxiter_25_N_v0.20.0-no-hint-run_1/output.jsonl 10 kjain14/testgeneval test true
|
||||
|
||||
|
||||
Command run (our approach):
|
||||
./evaluation/benchmarks/testgeneval/scripts/run_infer.sh llm.eval_gpt HEAD CodeActAgent -1 25 10 kjain14/testgeneval test 1 ../TestGenEval/results/testgeneval/preds/gpt-4o-2024-08-06__testgeneval__0.2__test.jsonl
|
||||
|
||||
@@ -41,7 +41,7 @@ from evaluation.utils.shared import (
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
)
|
||||
from openhands.core.config import OpenHandsConfig, SandboxConfig, get_parser
|
||||
from openhands.core.config import OpenHandsConfig, SandboxConfig, get_evaluation_parser
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime
|
||||
from openhands.events.action import CmdRunAction
|
||||
@@ -484,7 +484,7 @@ def count_and_log_fields(evaluated_predictions, fields, key):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--input-file', type=str, required=True, help='Path to input predictions file'
|
||||
)
|
||||
|
||||
@@ -37,8 +37,8 @@ from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
SandboxConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -491,7 +491,7 @@ def prepare_dataset_pre(dataset: pd.DataFrame, filter_column: str) -> pd.DataFra
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -18,8 +18,8 @@ from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_agent_config_arg,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -197,7 +197,7 @@ def run_evaluator(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--task-image-name',
|
||||
type=str,
|
||||
|
||||
@@ -19,8 +19,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -157,7 +157,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -31,8 +31,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -565,7 +565,7 @@ SWEGYM_EXCLUDE_IDS = [
|
||||
]
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser = get_evaluation_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
FILE_VARIANTS_1,
|
||||
FILE_VARIANTS_2,
|
||||
@@ -10,20 +10,20 @@ import {
|
||||
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
|
||||
*/
|
||||
|
||||
describe("FileService", () => {
|
||||
describe("OpenHands File API", () => {
|
||||
it("should get a list of files", async () => {
|
||||
await expect(FileService.getFiles("test-conversation-id")).resolves.toEqual(
|
||||
await expect(OpenHands.getFiles("test-conversation-id")).resolves.toEqual(
|
||||
FILE_VARIANTS_1,
|
||||
);
|
||||
|
||||
await expect(
|
||||
FileService.getFiles("test-conversation-id-2"),
|
||||
OpenHands.getFiles("test-conversation-id-2"),
|
||||
).resolves.toEqual(FILE_VARIANTS_2);
|
||||
});
|
||||
|
||||
it("should get content of a file", async () => {
|
||||
await expect(
|
||||
FileService.getFile("test-conversation-id", "file1.txt"),
|
||||
OpenHands.getFile("test-conversation-id", "file1.txt"),
|
||||
).resolves.toEqual("Content of file1.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
|
||||
+28
-9
@@ -85,9 +85,10 @@ describe("ConversationPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([
|
||||
...mockConversations,
|
||||
]);
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the conversations", async () => {
|
||||
@@ -101,7 +102,10 @@ describe("ConversationPanel", () => {
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -195,7 +199,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const deleteUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
@@ -249,7 +256,10 @@ describe("ConversationPanel", () => {
|
||||
it("should refetch data on rerenders", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([...mockConversations]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
function PanelWithToggle() {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
@@ -343,7 +353,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockRunningConversations,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -407,7 +420,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
@@ -492,7 +508,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockMixedStatusConversations,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
|
||||
@@ -82,5 +82,11 @@ describe("extractModelAndProvider", () => {
|
||||
model: "claude-opus-4-20250514",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-opus-4-1-20250805")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-1-20250805",
|
||||
separator: "/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenHands: Code Less, Make More"
|
||||
/>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>OpenHands</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+247
-135
@@ -12,16 +12,16 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"@react-router/node": "^7.8.0",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
@@ -33,9 +33,9 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.536.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.258.5",
|
||||
"posthog-js": "^1.259.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-router": "^7.8.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
@@ -53,7 +53,7 @@
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.6",
|
||||
"vite": "^7.1.1",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -63,7 +63,7 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@react-router/dev": "^7.8.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
@@ -4388,11 +4388,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.7.1.tgz",
|
||||
"integrity": "sha512-ByfgHmAyfx/JQYN/QwUx1sFJlBA5Z3HQAZ638wHSb+m6khWtHqSaKCvPqQh1P00wdEAeV3tX5L1aUM/ceCF6+w==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.8.0.tgz",
|
||||
"integrity": "sha512-5NA9yLZComM+kCD3zNPL3rjrAFjzzODY8hjAJlpz/6jpyXoF28W8QTSo8rxc56XVNLONM75Y5nq1wzeEcWFFKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.27.7",
|
||||
"@babel/generator": "^7.27.5",
|
||||
@@ -4402,7 +4401,9 @@
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@babel/types": "^7.27.7",
|
||||
"@npmcli/package-json": "^4.0.1",
|
||||
"@react-router/node": "7.7.1",
|
||||
"@react-router/node": "7.8.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-rsc": "0.4.11",
|
||||
"arg": "^5.0.1",
|
||||
"babel-dead-code-elimination": "^1.0.6",
|
||||
"chokidar": "^4.0.0",
|
||||
@@ -4429,8 +4430,8 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"react-router": "^7.7.1",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"react-router": "^7.8.0",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.1.0 || ^6.0.0 || ^7.0.0",
|
||||
"wrangler": "^3.28.2 || ^4.0.0"
|
||||
@@ -4447,6 +4448,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/@vitejs/plugin-react/node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/jsesc": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
|
||||
@@ -4460,33 +4496,10 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/express": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.7.1.tgz",
|
||||
"integrity": "sha512-OEZwIM7i/KPSDjwVRg3LqeNIwG41U+SeFOwMjhZRFfyrnwghHfvWsDajf73r4ccMh+RRHcP1GIN6VSU3XZk7MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.17.1 || ^5",
|
||||
"react-router": "7.7.1",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/node": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.7.1.tgz",
|
||||
"integrity": "sha512-EHd6PEcw2nmcJmcYTPA0MmRWSqOaJ/meycfCp0ADA9T/6b7+fUHfr9XcNyf7UeZtYwu4zGyuYfPmLU5ic6Ugyg==",
|
||||
"license": "MIT",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.8.0.tgz",
|
||||
"integrity": "sha512-/FFN9vqI2EHPwDCHTvsMInhrYvwJ5SlCeyUr1oWUxH47JyYkooVFks5++M4VkrTgj2ZBsMjPPKy0xRNTQdtBDA==",
|
||||
"dependencies": {
|
||||
"@mjackson/node-fetch-server": "^0.2.0"
|
||||
},
|
||||
@@ -4494,7 +4507,7 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-router": "7.7.1",
|
||||
"react-router": "7.8.0",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4504,13 +4517,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/serve": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.7.1.tgz",
|
||||
"integrity": "sha512-LyAiX+oI+6O6j2xWPUoKW+cgayUf3USBosSMv73Jtwi99XUhSDu2MUhM+BB+AbrYRubauZ83QpZTROiXoaf8jA==",
|
||||
"license": "MIT",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.8.0.tgz",
|
||||
"integrity": "sha512-DokCv1GfOMt9KHu+k3WYY9sP5nOEzq7za+Vi3dWPHoY5oP0wgv8S4DnTPU08ASY8iFaF38NAzapbSFfu6Xfr0Q==",
|
||||
"dependencies": {
|
||||
"@react-router/express": "7.7.1",
|
||||
"@react-router/node": "7.7.1",
|
||||
"@react-router/express": "7.8.0",
|
||||
"@react-router/node": "7.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.19.2",
|
||||
"get-port": "5.1.1",
|
||||
@@ -4524,7 +4536,28 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-router": "7.7.1"
|
||||
"react-router": "7.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/serve/node_modules/@react-router/express": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.8.0.tgz",
|
||||
"integrity": "sha512-lNUwux5IfMqczIL3gXZ/mauPUoVz65fSLPnUTkP7hkh/P7fcsPtYkmcixuaWb+882lY+Glf157OdoIMbcSMBaA==",
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.17.1 || ^5",
|
||||
"react-router": "7.8.0",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/calendar": {
|
||||
@@ -5226,10 +5259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"license": "MIT"
|
||||
"version": "1.0.0-beta.30",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz",
|
||||
"integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.2.0",
|
||||
@@ -6072,6 +6104,60 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.11",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||
@@ -6175,10 +6261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.1.tgz",
|
||||
"integrity": "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw==",
|
||||
"license": "MIT",
|
||||
"version": "5.84.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.2.tgz",
|
||||
"integrity": "sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.83.1"
|
||||
},
|
||||
@@ -6951,20 +7036,19 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"license": "MIT",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.0.tgz",
|
||||
"integrity": "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.30",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
@@ -6979,6 +7063,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-rsc": {
|
||||
"version": "0.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-rsc/-/plugin-rsc-0.4.11.tgz",
|
||||
"integrity": "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@mjackson/node-fetch-server": "^0.7.0",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17",
|
||||
"periscopic": "^4.0.2",
|
||||
"turbo-stream": "^3.1.0",
|
||||
"vitefu": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"vite": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-rsc/node_modules/@mjackson/node-fetch-server": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.7.0.tgz",
|
||||
"integrity": "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||
@@ -7161,7 +7271,6 @@
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
@@ -7174,7 +7283,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -7315,8 +7423,7 @@
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -7681,7 +7788,6 @@
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
@@ -7705,7 +7811,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -7713,8 +7818,7 @@
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
@@ -8340,7 +8444,6 @@
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -8352,7 +8455,6 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -8367,7 +8469,6 @@
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -8375,8 +8476,7 @@
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.45.0",
|
||||
@@ -8731,7 +8831,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
@@ -8857,7 +8956,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -9210,8 +9308,7 @@
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
@@ -9884,7 +9981,6 @@
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -9923,7 +10019,6 @@
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -9969,7 +10064,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -9977,8 +10071,7 @@
|
||||
"node_modules/express/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
@@ -10103,7 +10196,6 @@
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
@@ -10121,7 +10213,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -10129,8 +10220,7 @@
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
@@ -10267,7 +10357,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -10317,7 +10406,6 @@
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -10930,7 +11018,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
@@ -11039,7 +11126,6 @@
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
@@ -11168,7 +11254,6 @@
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
@@ -11517,6 +11602,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
@@ -12625,10 +12719,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.536.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
|
||||
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
|
||||
"license": "ISC",
|
||||
"version": "0.539.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
|
||||
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -12999,7 +13092,6 @@
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13014,7 +13106,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
@@ -13033,7 +13124,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13619,7 +13709,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
@@ -14224,7 +14313,6 @@
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
@@ -14427,7 +14515,6 @@
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -14495,8 +14582,7 @@
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
@@ -14524,6 +14610,17 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/periscopic": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-4.0.2.tgz",
|
||||
"integrity": "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"is-reference": "^3.0.2",
|
||||
"zimmerframe": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -14648,10 +14745,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.258.6",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.258.6.tgz",
|
||||
"integrity": "sha512-vL5AGG+rOoRg3LGquMfBPO55jD4bGl0CiV44SHdHAoBnOVDDAqxczRGDqMdxor+VLx3/ofTFOJ2FNprfAHp70Q==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.259.0.tgz",
|
||||
"integrity": "sha512-6usLnJshky8fQ82ask7PIJh4BSFOU0VkRbFg8Zanm/HIlYMG1VOdRWlToA63JXeO7Bzm9TuREq1wFm5U2VEVCg==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -14825,7 +14921,6 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
@@ -14910,7 +15005,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -14919,7 +15013,6 @@
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
@@ -15126,10 +15219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz",
|
||||
"integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==",
|
||||
"license": "MIT",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz",
|
||||
"integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
@@ -15898,7 +15990,6 @@
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -15922,7 +16013,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -15930,14 +16020,12 @@
|
||||
"node_modules/send/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -15946,7 +16034,6 @@
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
@@ -16015,8 +16102,7 @@
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
@@ -17054,7 +17140,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
@@ -17180,6 +17265,12 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turbo-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-3.1.0.tgz",
|
||||
"integrity": "sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -17210,7 +17301,6 @@
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
@@ -17438,7 +17528,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -17559,7 +17648,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
@@ -17638,16 +17726,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
|
||||
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==",
|
||||
"license": "MIT",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.1.tgz",
|
||||
"integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.40.0",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
},
|
||||
"bin": {
|
||||
@@ -17816,6 +17903,25 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
|
||||
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
|
||||
"dev": true,
|
||||
"workspaces": [
|
||||
"tests/deps/*",
|
||||
"tests/projects/*",
|
||||
"tests/projects/workspace/packages/*"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
@@ -18418,6 +18524,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"@react-router/node": "^7.8.0",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
@@ -32,9 +32,9 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.536.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.258.5",
|
||||
"posthog-js": "^1.259.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-router": "^7.8.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
@@ -52,7 +52,7 @@
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.6",
|
||||
"vite": "^7.1.1",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -87,7 +87,7 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@react-router/dev": "^7.8.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GetFilesResponse, GetFileResponse } from "./file-service.types";
|
||||
import { getConversationUrl } from "../conversation.utils";
|
||||
import { FileUploadSuccessResponse } from "../open-hands.types";
|
||||
|
||||
export class FileService {
|
||||
/**
|
||||
* Retrieve the list of files available in the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Path to list files from. If provided, it lists all the files in the given path
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(
|
||||
conversationId: string,
|
||||
path?: string,
|
||||
): Promise<GetFilesResponse> {
|
||||
const url = `${getConversationUrl(conversationId)}/list-files`;
|
||||
const { data } = await openHands.get<GetFilesResponse>(url, {
|
||||
params: { path },
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param files List of files.
|
||||
* @returns list of uploaded files, list of skipped files
|
||||
*/
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const url = `${getConversationUrl(conversationId)}/upload-files`;
|
||||
const response = await openHands.post<FileUploadSuccessResponse>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type GetFilesResponse = string[];
|
||||
|
||||
export interface GetFileResponse {
|
||||
code: string;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
interface GetPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
static async getPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
GetMicroagentPromptResponse,
|
||||
CreateMicroagent,
|
||||
MicroagentContentResponse,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
@@ -280,17 +283,27 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
static async getUserConversations(
|
||||
limit: number = 20,
|
||||
pageId?: string,
|
||||
): Promise<ResultSet<Conversation>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
if (pageId) {
|
||||
params.append("page_id", pageId);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=100",
|
||||
`/api/conversations?${params.toString()}`,
|
||||
);
|
||||
return data.results;
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchConversations(
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
@@ -618,12 +631,11 @@ class OpenHands {
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetMicroagentPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
const url = `${this.getConversationUrl(conversationId)}/remember-prompt`;
|
||||
const { data } = await openHands.get<GetMicroagentPromptResponse>(url, {
|
||||
params: { event_id: eventId },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.prompt;
|
||||
}
|
||||
@@ -640,6 +652,69 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of files available in the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Path to list files from. If provided, it lists all the files in the given path
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(
|
||||
conversationId: string,
|
||||
path?: string,
|
||||
): Promise<GetFilesResponse> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/list-files`;
|
||||
const { data } = await openHands.get<GetFilesResponse>(url, {
|
||||
params: { path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param files List of files.
|
||||
* @returns list of uploaded files, list of skipped files
|
||||
*/
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const url = `${this.getConversationUrl(conversationId)}/upload-files`;
|
||||
const response = await openHands.post<FileUploadSuccessResponse>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
...this.getConversationHeaders(),
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user installation IDs
|
||||
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
|
||||
|
||||
@@ -158,3 +158,9 @@ export interface MicroagentContentResponse {
|
||||
git_provider: Provider;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export type GetFilesResponse = string[];
|
||||
|
||||
export interface GetFileResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
@@ -40,12 +41,30 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
error,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
const scrollContainerRef = useInfiniteScroll({
|
||||
hasNextPage: !!hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold: 200, // Load more when 200px from bottom
|
||||
});
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
@@ -102,11 +121,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
ref={(node) => {
|
||||
// TODO: Combine both refs somehow
|
||||
if (ref.current !== node) ref.current = node;
|
||||
if (scrollContainerRef.current !== node)
|
||||
scrollContainerRef.current = node;
|
||||
}}
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-base-secondary rounded-xl overflow-y-auto absolute"
|
||||
>
|
||||
{isFetching && (
|
||||
{isFetching && conversations.length === 0 && (
|
||||
<div className="w-full h-full absolute flex justify-center items-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
@@ -156,6 +180,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Loading indicator for fetching more conversations */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
mcp_config: settings.MCP_CONFIG,
|
||||
enable_proactive_conversation_starters:
|
||||
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS,
|
||||
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
|
||||
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
|
||||
git_user_name:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useUploadFiles = () =>
|
||||
useMutation({
|
||||
mutationKey: ["upload-files"],
|
||||
mutationFn: (variables: { conversationId: string; files: File[] }) =>
|
||||
FileService.uploadFiles(variables.conversationId!, variables.files),
|
||||
OpenHands.uploadFiles(variables.conversationId!, variables.files),
|
||||
onSuccess: async () => {},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGetMicroagents = (microagentDirectory: string) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", "microagents", conversationId, microagentDirectory],
|
||||
queryFn: () => FileService.getFiles(conversationId!, microagentDirectory),
|
||||
queryFn: () => OpenHands.getFiles(conversationId!, microagentDirectory),
|
||||
enabled: !!conversationId,
|
||||
select: (data) =>
|
||||
data.map((fileName) => fileName.replace(microagentDirectory, "")),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
|
||||
export const useMicroagentPrompt = (eventId: number) => {
|
||||
@@ -7,7 +7,7 @@ export const useMicroagentPrompt = (eventId: number) => {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["memory", "prompt", conversationId, eventId],
|
||||
queryFn: () => MemoryService.getPrompt(conversationId!, eventId),
|
||||
queryFn: () => OpenHands.getMicroagentPrompt(conversationId!, eventId),
|
||||
enabled: !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const usePaginatedConversations = (limit: number = 20) => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["user", "conversations", "paginated", limit],
|
||||
queryFn: ({ pageParam }) =>
|
||||
OpenHands.getUserConversations(limit, pageParam),
|
||||
enabled: !!userIsAuthenticated,
|
||||
getNextPageParam: (lastPage) => lastPage.next_page_id,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import OpenHands from "#/api/open-hands";
|
||||
export const useSearchConversations = (
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
|
||||
@@ -25,6 +25,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis,
|
||||
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
|
||||
SEARCH_API_KEY: apiSettings.search_api_key || "",
|
||||
MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const useUserConversations = () => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "conversations"],
|
||||
queryFn: OpenHands.getUserConversations,
|
||||
enabled: !!userIsAuthenticated,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export const useInfiniteScroll = ({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold = 100,
|
||||
}: UseInfiniteScrollOptions) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current || isFetchingNextPage || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (isNearBottom) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, threshold]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
return containerRef;
|
||||
};
|
||||
@@ -151,6 +151,7 @@ export enum I18nKey {
|
||||
SETTINGS$MAX_BUDGET_PER_TASK = "SETTINGS$MAX_BUDGET_PER_TASK",
|
||||
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
|
||||
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
|
||||
SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS",
|
||||
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
|
||||
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
|
||||
SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS",
|
||||
|
||||
@@ -2415,6 +2415,22 @@
|
||||
"tr": "GitHub'da Görevler Öner",
|
||||
"uk": "Запропонувати завдання на GitHub"
|
||||
},
|
||||
"SETTINGS$SOLVABILITY_ANALYSIS": {
|
||||
"en": "Enable Solvability Analysis",
|
||||
"ja": "解決可能性分析を有効にする",
|
||||
"zh-CN": "启用可解决性分析",
|
||||
"zh-TW": "啟用可解決性分析",
|
||||
"ko-KR": "해결 가능성 분석 활성화",
|
||||
"de": "Lösbarkeitsanalyse aktivieren",
|
||||
"no": "Aktiver løsningsanalyse",
|
||||
"it": "Abilita analisi di risolvibilità",
|
||||
"pt": "Ativar análise de solucionabilidade",
|
||||
"es": "Habilitar análisis de solvencia",
|
||||
"ar": "تمكين تحليل القابلية للحل",
|
||||
"fr": "Activer l'analyse de solvabilité",
|
||||
"tr": "Çözünürlük Analizini Etkinleştir",
|
||||
"uk": "Увімкнути аналіз розв'язності"
|
||||
},
|
||||
"SETTINGS$SEARCH_API_KEY": {
|
||||
"en": "Search API Key (Tavily)",
|
||||
"ja": "検索APIキー (Tavily)",
|
||||
|
||||
@@ -30,6 +30,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
|
||||
enable_proactive_conversation_starters:
|
||||
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS,
|
||||
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
|
||||
max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,10 @@ function AppSettingsScreen() {
|
||||
proactiveConversationsSwitchHasChanged,
|
||||
setProactiveConversationsSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [
|
||||
solvabilityAnalysisSwitchHasChanged,
|
||||
setSolvabilityAnalysisSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
|
||||
React.useState(false);
|
||||
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
|
||||
@@ -61,6 +65,9 @@ function AppSettingsScreen() {
|
||||
formData.get("enable-proactive-conversations-switch")?.toString() ===
|
||||
"on";
|
||||
|
||||
const enableSolvabilityAnalysis =
|
||||
formData.get("enable-solvability-analysis-switch")?.toString() === "on";
|
||||
|
||||
const maxBudgetPerTaskValue = formData
|
||||
.get("max-budget-per-task-input")
|
||||
?.toString();
|
||||
@@ -79,6 +86,7 @@ function AppSettingsScreen() {
|
||||
user_consents_to_analytics: enableAnalytics,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
|
||||
ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis,
|
||||
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
|
||||
GIT_USER_NAME: gitUserName,
|
||||
GIT_USER_EMAIL: gitUserEmail,
|
||||
@@ -136,6 +144,13 @@ function AppSettingsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => {
|
||||
const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS;
|
||||
setSolvabilityAnalysisSwitchHasChanged(
|
||||
checked !== currentSolvabilityAnalysis,
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
|
||||
const newValue = parseMaxBudgetPerTask(value);
|
||||
const currentValue = settings?.MAX_BUDGET_PER_TASK;
|
||||
@@ -157,6 +172,7 @@ function AppSettingsScreen() {
|
||||
!analyticsSwitchHasChanged &&
|
||||
!soundNotificationsSwitchHasChanged &&
|
||||
!proactiveConversationsSwitchHasChanged &&
|
||||
!solvabilityAnalysisSwitchHasChanged &&
|
||||
!maxBudgetPerTaskHasChanged &&
|
||||
!gitUserNameHasChanged &&
|
||||
!gitUserEmailHasChanged;
|
||||
@@ -209,6 +225,17 @@ function AppSettingsScreen() {
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{config?.APP_MODE === "saas" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-solvability-analysis-switch"
|
||||
name="enable-solvability-analysis-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOLVABILITY_ANALYSIS}
|
||||
onToggle={checkIfSolvabilityAnalysisSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
name="max-budget-per-task-input"
|
||||
@@ -222,7 +249,7 @@ function AppSettingsScreen() {
|
||||
className="w-full max-w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
|
||||
<div className="border-t border-t-tertiary pt-6 mt-2">
|
||||
<div className="border-t border-t-tertiary pt-6 mt-2 hidden">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
|
||||
</h3>
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
ENABLE_SOUND_NOTIFICATIONS: false,
|
||||
USER_CONSENTS_TO_ANALYTICS: false,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
ENABLE_SOLVABILITY_ANALYSIS: false,
|
||||
SEARCH_API_KEY: "",
|
||||
IS_NEW_USER: true,
|
||||
MAX_BUDGET_PER_TASK: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export type Settings = {
|
||||
ENABLE_DEFAULT_CONDENSER: boolean;
|
||||
ENABLE_SOUND_NOTIFICATIONS: boolean;
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
|
||||
ENABLE_SOLVABILITY_ANALYSIS: boolean;
|
||||
USER_CONSENTS_TO_ANALYTICS: boolean | null;
|
||||
SEARCH_API_KEY?: string;
|
||||
IS_NEW_USER?: boolean;
|
||||
@@ -68,6 +69,7 @@ export type ApiSettings = {
|
||||
enable_default_condenser: boolean;
|
||||
enable_sound_notifications: boolean;
|
||||
enable_proactive_conversation_starters: boolean;
|
||||
enable_solvability_analysis: boolean;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
search_api_key?: string;
|
||||
provider_tokens_set: Partial<Record<Provider, string | null>>;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const VERIFIED_MODELS = [
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"gemini-2.5-pro",
|
||||
"o4-mini",
|
||||
"deepseek-chat",
|
||||
@@ -22,11 +23,13 @@ export const VERIFIED_MODELS = [
|
||||
"devstral-medium-2507",
|
||||
"kimi-k2-0711-preview",
|
||||
"qwen3-coder-480b",
|
||||
"gpt-5-2025-08-07",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
|
||||
export const VERIFIED_OPENAI_MODELS = [
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
@@ -47,6 +50,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
];
|
||||
|
||||
// LiteLLM does not return the compatible Mistral models with the provider, so we list them here to set them ourselves
|
||||
@@ -61,7 +65,9 @@ export const VERIFIED_MISTRAL_MODELS = [
|
||||
// (e.g., they return `claude-sonnet-4-20250514` instead of `openhands/claude-sonnet-4-20250514`)
|
||||
export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"gpt-5-2025-08-07",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"gemini-2.5-pro",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
|
||||
@@ -106,10 +106,15 @@ class CodeActAgent(Agent):
|
||||
def _get_tools(self) -> list['ChatCompletionToolParam']:
|
||||
# For these models, we use short tool descriptions ( < 1024 tokens)
|
||||
# to avoid hitting the OpenAI token limit for tool descriptions.
|
||||
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
|
||||
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-4', 'o3', 'o1', 'o4']
|
||||
|
||||
use_short_tool_desc = False
|
||||
if self.llm is not None:
|
||||
# For historical reasons, previously OpenAI enforces max function description length of 1k characters
|
||||
# https://community.openai.com/t/function-call-description-max-length/529902
|
||||
# But it no longer seems to be an issue recently
|
||||
# https://community.openai.com/t/was-the-character-limit-for-schema-descriptions-upgraded/1225975
|
||||
# Tested on GPT-5 and longer description still works. But we still keep the logic to be safe for older models.
|
||||
use_short_tool_desc = any(
|
||||
model_substr in self.llm.config.model
|
||||
for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import sys
|
||||
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools.prompt import refine_prompt
|
||||
from openhands.llm.tool_names import EXECUTE_BASH_TOOL_NAME
|
||||
|
||||
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
@@ -35,12 +34,6 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
|
||||
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
if sys.platform == 'win32':
|
||||
return prompt.replace('bash', 'powershell')
|
||||
return prompt
|
||||
|
||||
|
||||
def create_cmd_run_tool(
|
||||
use_short_description: bool = False,
|
||||
) -> ChatCompletionToolParam:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
"""
|
||||
Refines the prompt based on the platform.
|
||||
|
||||
On Windows systems, replaces 'bash' with 'powershell' and 'execute_bash' with 'execute_powershell'
|
||||
to ensure commands work correctly on the Windows platform.
|
||||
|
||||
Args:
|
||||
prompt: The prompt text to refine
|
||||
|
||||
Returns:
|
||||
The refined prompt text
|
||||
"""
|
||||
if sys.platform == 'win32':
|
||||
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
|
||||
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
|
||||
result = re.sub(
|
||||
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
|
||||
)
|
||||
# Then replace standalone 'bash' with 'powershell'
|
||||
result = re.sub(
|
||||
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
|
||||
)
|
||||
return result
|
||||
return prompt
|
||||
@@ -0,0 +1 @@
|
||||
"""OpenHands CLI module."""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Main entry point for OpenHands CLI with subcommand support."""
|
||||
|
||||
import sys
|
||||
|
||||
import openhands
|
||||
import openhands.cli.suppress_warnings # noqa: F401
|
||||
from openhands.cli.gui_launcher import launch_gui_server
|
||||
from openhands.cli.main import run_cli_command
|
||||
from openhands.core.config import get_cli_parser
|
||||
from openhands.core.config.arg_utils import get_subparser
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point with subcommand support and backward compatibility."""
|
||||
parser = get_cli_parser()
|
||||
|
||||
# If user only asks for --help or -h without a subcommand
|
||||
if len(sys.argv) == 2 and sys.argv[1] in ('--help', '-h'):
|
||||
# Print top-level help
|
||||
print(parser.format_help())
|
||||
|
||||
# Also print help for `cli` subcommand
|
||||
print('\n' + '=' * 80)
|
||||
print('CLI command help:\n')
|
||||
|
||||
cli_parser = get_subparser(parser, 'cli')
|
||||
print(cli_parser.format_help())
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# Special case: no subcommand provided, simulate "openhands cli"
|
||||
if len(sys.argv) == 1 or (
|
||||
len(sys.argv) > 1 and sys.argv[1] not in ['cli', 'serve']
|
||||
):
|
||||
# Inject 'cli' as default command
|
||||
sys.argv.insert(1, 'cli')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'version') and args.version:
|
||||
print(f'OpenHands CLI version: {openhands.get_version()}')
|
||||
sys.exit(0)
|
||||
|
||||
if args.command == 'serve':
|
||||
launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
|
||||
elif args.command == 'cli' or args.command is None:
|
||||
run_cli_command(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""GUI launcher for OpenHands CLI."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands import __version__
|
||||
|
||||
|
||||
def _format_docker_command_for_logging(cmd: list[str]) -> str:
|
||||
"""Format a Docker command for logging with grey color.
|
||||
|
||||
Args:
|
||||
cmd (list[str]): The Docker command as a list of strings
|
||||
|
||||
Returns:
|
||||
str: The formatted command string in grey HTML color
|
||||
"""
|
||||
cmd_str = ' '.join(cmd)
|
||||
return f'<grey>Running Docker command: {cmd_str}</grey>'
|
||||
|
||||
|
||||
def check_docker_requirements() -> bool:
|
||||
"""Check if Docker is installed and running.
|
||||
|
||||
Returns:
|
||||
bool: True if Docker is available and running, False otherwise.
|
||||
"""
|
||||
# Check if Docker is installed
|
||||
if not shutil.which('docker'):
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Docker is not installed or not in PATH.</ansired>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>'
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
# Check if Docker daemon is running
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'info'], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Docker daemon is not running.</ansired>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('<grey>Please start Docker and try again.</grey>')
|
||||
)
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Failed to check Docker status.</ansired>')
|
||||
)
|
||||
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_config_dir_exists() -> Path:
|
||||
"""Ensure the OpenHands configuration directory exists and return its path."""
|
||||
config_dir = Path.home() / '.openhands'
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
|
||||
"""Launch the OpenHands GUI server using Docker.
|
||||
|
||||
Args:
|
||||
mount_cwd: If True, mount the current working directory into the container.
|
||||
gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker.
|
||||
"""
|
||||
print_formatted_text(
|
||||
HTML('<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
# Check Docker requirements
|
||||
if not check_docker_requirements():
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure config directory exists
|
||||
config_dir = ensure_config_dir_exists()
|
||||
|
||||
# Get the current version for the Docker image
|
||||
version = __version__
|
||||
runtime_image = f'docker.all-hands.dev/all-hands-ai/runtime:{version}-nikolaik'
|
||||
app_image = f'docker.all-hands.dev/all-hands-ai/openhands:{version}'
|
||||
|
||||
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
|
||||
|
||||
# Pull the runtime image first
|
||||
pull_cmd = ['docker', 'pull', runtime_image]
|
||||
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
|
||||
try:
|
||||
subprocess.run(
|
||||
pull_cmd,
|
||||
check=True,
|
||||
timeout=300, # 5 minutes timeout
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Failed to pull runtime image.</ansired>')
|
||||
)
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Timeout while pulling runtime image.</ansired>')
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('<grey>The server will be available at: http://localhost:3000</grey>')
|
||||
)
|
||||
print_formatted_text(HTML('<grey>Press Ctrl+C to stop the server.</grey>'))
|
||||
print_formatted_text('')
|
||||
|
||||
# Build the Docker command
|
||||
docker_cmd = [
|
||||
'docker',
|
||||
'run',
|
||||
'-it',
|
||||
'--rm',
|
||||
'--pull=always',
|
||||
'-e',
|
||||
f'SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}',
|
||||
'-e',
|
||||
'LOG_ALL_EVENTS=true',
|
||||
'-v',
|
||||
'/var/run/docker.sock:/var/run/docker.sock',
|
||||
'-v',
|
||||
f'{config_dir}:/.openhands',
|
||||
]
|
||||
|
||||
# Add GPU support if requested
|
||||
if gpu:
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>')
|
||||
)
|
||||
# Add the --gpus all flag to enable all GPUs
|
||||
docker_cmd.insert(2, '--gpus')
|
||||
docker_cmd.insert(3, 'all')
|
||||
# Add environment variable to pass GPU support to sandbox containers
|
||||
docker_cmd.extend(
|
||||
[
|
||||
'-e',
|
||||
'SANDBOX_ENABLE_GPU=true',
|
||||
]
|
||||
)
|
||||
|
||||
# Add current working directory mount if requested
|
||||
if mount_cwd:
|
||||
cwd = Path.cwd()
|
||||
# Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
|
||||
docker_cmd.extend(
|
||||
[
|
||||
'-e',
|
||||
f'SANDBOX_VOLUMES={cwd}:/workspace:rw',
|
||||
]
|
||||
)
|
||||
|
||||
# Set user ID for Unix-like systems only
|
||||
if os.name != 'nt': # Not Windows
|
||||
try:
|
||||
user_id = subprocess.check_output(['id', '-u'], text=True).strip()
|
||||
docker_cmd.extend(['-e', f'SANDBOX_USER_ID={user_id}'])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# If 'id' command fails or doesn't exist, skip setting user ID
|
||||
pass
|
||||
# Print the folder that will be mounted to inform the user
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<ansigreen>📂 Mounting current directory:</ansigreen> <ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> <ansiyellow>/workspace</ansiyellow>'
|
||||
)
|
||||
)
|
||||
|
||||
docker_cmd.extend(
|
||||
[
|
||||
'-p',
|
||||
'3000:3000',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--name',
|
||||
'openhands-app',
|
||||
app_image,
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
# Log and run the Docker command
|
||||
print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
|
||||
subprocess.run(docker_cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Failed to start OpenHands GUI server.</ansired>')
|
||||
)
|
||||
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>')
|
||||
)
|
||||
sys.exit(0)
|
||||
+13
-21
@@ -45,7 +45,6 @@ from openhands.controller import AgentController
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
parse_arguments,
|
||||
setup_config_from_args,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
@@ -129,12 +128,13 @@ async def run_session(
|
||||
conversation_instructions: str | None = None,
|
||||
session_name: str | None = None,
|
||||
skip_banner: bool = False,
|
||||
conversation_id: str | None = None,
|
||||
) -> bool:
|
||||
reload_microagents = False
|
||||
new_session_requested = False
|
||||
exit_reason = ExitReason.INTENTIONAL
|
||||
|
||||
sid = generate_sid(config, session_name)
|
||||
sid = conversation_id or generate_sid(config, session_name)
|
||||
is_loaded = asyncio.Event()
|
||||
is_paused = asyncio.Event() # Event to track agent pause requests
|
||||
always_confirm_mode = False # Flag to enable always confirm mode
|
||||
@@ -523,10 +523,8 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
async def main_with_loop(loop: asyncio.AbstractEventLoop, args) -> None:
|
||||
"""Runs the agent in CLI mode."""
|
||||
args = parse_arguments()
|
||||
|
||||
# Set log level from command line argument if provided
|
||||
if args.log_level and isinstance(args.log_level, str):
|
||||
log_level = getattr(logging, str(args.log_level).upper())
|
||||
@@ -574,13 +572,9 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
|
||||
# Use settings from settings store if available and override with command line arguments
|
||||
if settings:
|
||||
# Handle agent configuration
|
||||
if args.agent_cls:
|
||||
config.default_agent = str(args.agent_cls)
|
||||
else:
|
||||
# settings.agent is not None because we check for it in setup_config_from_args
|
||||
assert settings.agent is not None
|
||||
config.default_agent = settings.agent
|
||||
# settings.agent is not None because we check for it in setup_config_from_args
|
||||
assert settings.agent is not None
|
||||
config.default_agent = settings.agent
|
||||
|
||||
# Handle LLM configuration with proper precedence:
|
||||
# 1. CLI parameters (-l) have highest precedence (already handled in setup_config_from_args)
|
||||
@@ -705,6 +699,7 @@ After reviewing the file, please ask the user what they would like to do with it
|
||||
task_str,
|
||||
session_name=args.name,
|
||||
skip_banner=banner_shown,
|
||||
conversation_id=args.conversation,
|
||||
)
|
||||
|
||||
# If a new session was requested, run it
|
||||
@@ -717,18 +712,19 @@ After reviewing the file, please ask the user what they would like to do with it
|
||||
get_runtime_cls(config.runtime).teardown(config)
|
||||
|
||||
|
||||
def main():
|
||||
def run_cli_command(args):
|
||||
"""Run the CLI command with proper error handling and cleanup."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(main_with_loop(loop))
|
||||
loop.run_until_complete(main_with_loop(loop, args))
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text('⚠️ Session was interrupted: interrupted\n')
|
||||
except ConnectionRefusedError as e:
|
||||
print(f'Connection refused: {e}')
|
||||
print_formatted_text(f'Connection refused: {e}')
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f'An error occurred: {e}')
|
||||
print_formatted_text(f'An error occurred: {e}')
|
||||
sys.exit(1)
|
||||
finally:
|
||||
try:
|
||||
@@ -741,9 +737,5 @@ def main():
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
print(f'Error during cleanup: {e}')
|
||||
print_formatted_text(f'Error during cleanup: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -27,7 +27,7 @@ from openhands.core.config.condenser_config import (
|
||||
CondenserPipelineConfig,
|
||||
ConversationWindowCondenserConfig,
|
||||
)
|
||||
from openhands.core.config.utils import OH_DEFAULT_AGENT
|
||||
from openhands.core.config.config_utils import OH_DEFAULT_AGENT
|
||||
from openhands.memory.condenser.impl.llm_summarizing_condenser import (
|
||||
LLMSummarizingCondenserConfig,
|
||||
)
|
||||
|
||||
+84
-10
@@ -11,6 +11,7 @@ import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
import markdown # type: ignore
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
@@ -65,6 +66,7 @@ MAX_RECENT_THOUGHTS = 5
|
||||
# Color and styling constants
|
||||
COLOR_GOLD = '#FFD700'
|
||||
COLOR_GREY = '#808080'
|
||||
COLOR_AGENT_BLUE = '#4682B4' # Steel blue - less saturated, works well on both light and dark backgrounds
|
||||
DEFAULT_STYLE = Style.from_dict(
|
||||
{
|
||||
'gold': COLOR_GOLD,
|
||||
@@ -236,13 +238,19 @@ def display_mcp_errors() -> None:
|
||||
|
||||
|
||||
# Prompt output display functions
|
||||
def display_thought_if_new(thought: str) -> None:
|
||||
"""Display a thought only if it hasn't been displayed recently."""
|
||||
def display_thought_if_new(thought: str, is_agent_message: bool = False) -> None:
|
||||
"""
|
||||
Display a thought only if it hasn't been displayed recently.
|
||||
|
||||
Args:
|
||||
thought: The thought to display
|
||||
is_agent_message: If True, apply agent styling and markdown rendering
|
||||
"""
|
||||
global recent_thoughts
|
||||
if thought and thought.strip():
|
||||
# Check if this thought was recently displayed
|
||||
if thought not in recent_thoughts:
|
||||
display_message(thought)
|
||||
display_message(thought, is_agent_message=is_agent_message)
|
||||
recent_thoughts.append(thought)
|
||||
# Keep only the most recent thoughts
|
||||
if len(recent_thoughts) > MAX_RECENT_THOUGHTS:
|
||||
@@ -255,7 +263,7 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
if isinstance(event, CmdRunAction):
|
||||
# For CmdRunAction, display thought first, then command
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_message(event.thought)
|
||||
display_thought_if_new(event.thought)
|
||||
|
||||
# Only display the command if it's not already confirmed
|
||||
# Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
|
||||
@@ -269,14 +277,15 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
elif isinstance(event, Action):
|
||||
# For other actions, display thoughts normally
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_message(event.thought)
|
||||
display_thought_if_new(event.thought)
|
||||
if hasattr(event, 'final_thought') and event.final_thought:
|
||||
display_message(event.final_thought)
|
||||
# Display final thoughts with agent styling
|
||||
display_message(event.final_thought, is_agent_message=True)
|
||||
|
||||
if isinstance(event, MessageAction):
|
||||
if event.source == EventSource.AGENT:
|
||||
# Check if this message content is a duplicate thought
|
||||
display_thought_if_new(event.content)
|
||||
# Display agent messages with styling and markdown rendering
|
||||
display_thought_if_new(event.content, is_agent_message=True)
|
||||
elif isinstance(event, CmdOutputObservation):
|
||||
display_command_output(event.content)
|
||||
elif isinstance(event, FileEditObservation):
|
||||
@@ -291,11 +300,76 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
display_error(event.content)
|
||||
|
||||
|
||||
def display_message(message: str) -> None:
|
||||
def display_message(message: str, is_agent_message: bool = False) -> None:
|
||||
"""
|
||||
Display a message in the terminal with markdown rendering.
|
||||
|
||||
Args:
|
||||
message: The message to display
|
||||
is_agent_message: If True, apply agent styling (blue color)
|
||||
"""
|
||||
message = message.strip()
|
||||
|
||||
if message:
|
||||
print_formatted_text(f'\n{message}')
|
||||
# Add spacing before the message
|
||||
print_formatted_text('')
|
||||
|
||||
try:
|
||||
# Convert markdown to HTML for all messages
|
||||
html_content = convert_markdown_to_html(message)
|
||||
|
||||
if is_agent_message:
|
||||
# Use prompt_toolkit's HTML renderer with the agent color
|
||||
print_formatted_text(
|
||||
HTML(f'<style fg="{COLOR_AGENT_BLUE}">{html_content}</style>')
|
||||
)
|
||||
else:
|
||||
# Regular message display with HTML rendering but default color
|
||||
print_formatted_text(HTML(html_content))
|
||||
except Exception as e:
|
||||
# If HTML rendering fails, fall back to plain text
|
||||
print(f'Warning: HTML rendering failed: {str(e)}', file=sys.stderr)
|
||||
if is_agent_message:
|
||||
print_formatted_text(
|
||||
FormattedText([('fg:' + COLOR_AGENT_BLUE, message)])
|
||||
)
|
||||
else:
|
||||
print_formatted_text(message)
|
||||
|
||||
|
||||
def convert_markdown_to_html(text: str) -> str:
|
||||
"""
|
||||
Convert markdown to HTML for prompt_toolkit's HTML renderer using the markdown library.
|
||||
|
||||
Args:
|
||||
text: Markdown text to convert
|
||||
|
||||
Returns:
|
||||
HTML formatted text with custom styling for headers and bullet points
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Use the markdown library to convert markdown to HTML
|
||||
# Enable the 'extra' extension for tables, fenced code, etc.
|
||||
html = markdown.markdown(text, extensions=['extra'])
|
||||
|
||||
# Customize headers
|
||||
for i in range(1, 7):
|
||||
# Get the appropriate number of # characters for this heading level
|
||||
prefix = '#' * i + ' '
|
||||
|
||||
# Replace <h1> with the prefix and bold text
|
||||
html = html.replace(f'<h{i}>', f'<b>{prefix}')
|
||||
html = html.replace(f'</h{i}>', '</b>\n')
|
||||
|
||||
# Customize bullet points to use dashes instead of dots with compact spacing
|
||||
html = html.replace('<ul>', '')
|
||||
html = html.replace('</ul>', '')
|
||||
html = html.replace('<li>', '- ')
|
||||
html = html.replace('</li>', '')
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def display_error(error: str) -> None:
|
||||
|
||||
@@ -150,6 +150,7 @@ def organize_models_and_providers(
|
||||
VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
|
||||
|
||||
VERIFIED_OPENAI_MODELS = [
|
||||
'gpt-5-2025-08-07',
|
||||
'o4-mini',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -164,6 +165,7 @@ VERIFIED_OPENAI_MODELS = [
|
||||
VERIFIED_ANTHROPIC_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229',
|
||||
@@ -183,7 +185,9 @@ VERIFIED_MISTRAL_MODELS = [
|
||||
|
||||
VERIFIED_OPENHANDS_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'gpt-5-2025-08-07',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'devstral-small-2507',
|
||||
'devstral-medium-2507',
|
||||
'o3',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.arg_utils import (
|
||||
get_cli_parser,
|
||||
get_evaluation_parser,
|
||||
get_headless_parser,
|
||||
)
|
||||
from openhands.core.config.cli_config import CLIConfig
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
@@ -15,7 +20,6 @@ from openhands.core.config.utils import (
|
||||
finalize_config,
|
||||
get_agent_config_arg,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_from_env,
|
||||
load_from_toml,
|
||||
load_openhands_config,
|
||||
@@ -41,7 +45,9 @@ __all__ = [
|
||||
'get_agent_config_arg',
|
||||
'get_llm_config_arg',
|
||||
'get_field_info',
|
||||
'get_parser',
|
||||
'get_cli_parser',
|
||||
'get_headless_parser',
|
||||
'get_evaluation_parser',
|
||||
'parse_arguments',
|
||||
'setup_config_from_args',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Centralized command line argument configuration for OpenHands CLI and headless modes."""
|
||||
|
||||
import argparse
|
||||
from argparse import ArgumentParser, _SubParsersAction
|
||||
|
||||
|
||||
def get_subparser(parser: ArgumentParser, name: str) -> ArgumentParser:
|
||||
for action in parser._actions:
|
||||
if isinstance(action, _SubParsersAction):
|
||||
if name in action.choices:
|
||||
return action.choices[name]
|
||||
raise ValueError(f"Subparser '{name}' not found")
|
||||
|
||||
|
||||
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add common arguments shared between CLI and headless modes."""
|
||||
parser.add_argument(
|
||||
'--config-file',
|
||||
type=str,
|
||||
default='config.toml',
|
||||
help='Path to the config file (default: config.toml in the current directory)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--task',
|
||||
type=str,
|
||||
default='',
|
||||
help='The task for the agent to perform',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-f',
|
||||
'--file',
|
||||
type=str,
|
||||
help='Path to a file containing the task. Overrides -t if both are provided.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
help='Session name',
|
||||
type=str,
|
||||
default='',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
help='Set the log level',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--llm-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default LLM ([llm] section in config.toml) config with the specified LLM config, e.g. "llama3" for [llm.llama3] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--agent-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default Agent ([agent] section in config.toml) config with the specified Agent config, e.g. "CodeAct" for [agent.CodeAct] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='store_true', help='Show version information'
|
||||
)
|
||||
|
||||
|
||||
def add_evaluation_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add arguments specific to evaluation mode."""
|
||||
# Evaluation-specific arguments
|
||||
parser.add_argument(
|
||||
'--eval-output-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
type=str,
|
||||
help='The directory to save evaluation output',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-n-limit',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The number of instances to evaluate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-num-workers',
|
||||
default=4,
|
||||
type=int,
|
||||
help='The number of workers to use for evaluation',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-note',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The note to add to the evaluation directory',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-ids',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The comma-separated list (in quotes) of IDs of the instances to evaluate',
|
||||
)
|
||||
|
||||
|
||||
def add_headless_specific_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add arguments specific to headless mode (full evaluation suite)."""
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--directory',
|
||||
type=str,
|
||||
help='The working directory for the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--agent-cls',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Name of the default agent to use',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--max-iterations',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The maximum number of iterations to run the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-b',
|
||||
'--max-budget-per-task',
|
||||
type=float,
|
||||
help='The maximum budget allowed per task, beyond which the agent will stop.',
|
||||
)
|
||||
# Additional headless-specific arguments
|
||||
parser.add_argument(
|
||||
'--no-auto-continue',
|
||||
help='Disable auto-continue responses in headless mode (i.e. headless will read from stdin instead of auto-continuing)',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--selected-repo',
|
||||
help='GitHub repository to clone (format: owner/repo)',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def get_cli_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for CLI mode with simplified argument set."""
|
||||
# Create a description with welcome message explaining available commands
|
||||
description = (
|
||||
'Welcome to OpenHands: Code Less, Make More\n\n'
|
||||
'OpenHands supports two main commands:\n'
|
||||
' serve - Launch the OpenHands GUI server (web interface)\n'
|
||||
' cli - Run OpenHands in CLI mode (terminal interface)\n\n'
|
||||
'Running "openhands" without a command is the same as "openhands cli"'
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=description,
|
||||
prog='openhands',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter, # Preserve formatting in description
|
||||
epilog='For more information about a command, run: openhands COMMAND --help',
|
||||
)
|
||||
|
||||
# Create subparsers
|
||||
subparsers = parser.add_subparsers(
|
||||
dest='command',
|
||||
title='commands',
|
||||
description='OpenHands supports two main commands:',
|
||||
metavar='COMMAND',
|
||||
)
|
||||
|
||||
# Add 'serve' subcommand
|
||||
serve_parser = subparsers.add_parser(
|
||||
'serve', help='Launch the OpenHands GUI server using Docker (web interface)'
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
'--mount-cwd',
|
||||
help='Mount the current working directory into the GUI server container',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
'--gpu',
|
||||
help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Add 'cli' subcommand - import all the existing CLI arguments
|
||||
cli_parser = subparsers.add_parser(
|
||||
'cli', help='Run OpenHands in CLI mode (terminal interface)'
|
||||
)
|
||||
add_common_arguments(cli_parser)
|
||||
|
||||
cli_parser.add_argument(
|
||||
'--override-cli-mode',
|
||||
help='Override the default settings for CLI mode',
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--conversation',
|
||||
help='The conversation id to continue',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_headless_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for headless mode with full argument set."""
|
||||
parser = argparse.ArgumentParser(description='Run the agent via CLI')
|
||||
add_common_arguments(parser)
|
||||
add_headless_specific_arguments(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def get_evaluation_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for evaluation mode."""
|
||||
parser = argparse.ArgumentParser(description='Run OpenHands in evaluation mode')
|
||||
add_common_arguments(parser)
|
||||
add_headless_specific_arguments(parser)
|
||||
add_evaluation_arguments(parser)
|
||||
return parser
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
@@ -302,6 +304,13 @@ class MCPConfig(BaseModel):
|
||||
raise ValueError(f'Invalid MCP configuration: {e}')
|
||||
return mcp_mapping
|
||||
|
||||
def merge(self, other: MCPConfig):
|
||||
return MCPConfig(
|
||||
sse_servers=self.sse_servers + other.sse_servers,
|
||||
stdio_servers=self.stdio_servers + other.stdio_servers,
|
||||
shttp_servers=self.shttp_servers + other.shttp_servers,
|
||||
)
|
||||
|
||||
|
||||
class OpenHandsMCPConfig:
|
||||
@staticmethod
|
||||
|
||||
@@ -72,6 +72,7 @@ class OpenHandsConfig(BaseModel):
|
||||
file_store_path: str = Field(default='~/.openhands')
|
||||
file_store_web_hook_url: str | None = Field(default=None)
|
||||
file_store_web_hook_headers: dict | None = Field(default=None)
|
||||
file_store_web_hook_batch: bool = Field(default=False)
|
||||
enable_browser: bool = Field(default=True)
|
||||
save_trajectory_path: str | None = Field(default=None)
|
||||
save_screenshots_in_trajectory: bool = Field(default=False)
|
||||
|
||||
@@ -15,15 +15,12 @@ from pydantic import BaseModel, SecretStr, ValidationError
|
||||
from openhands import __version__
|
||||
from openhands.core import logger
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.arg_utils import get_headless_parser
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
condenser_config_from_toml_section,
|
||||
create_condenser_config,
|
||||
)
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
OH_MAX_ITERATIONS,
|
||||
)
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.kubernetes_config import KubernetesConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
@@ -674,142 +671,9 @@ def get_condenser_config_arg(
|
||||
return None
|
||||
|
||||
|
||||
# Command line arguments
|
||||
def get_parser() -> argparse.ArgumentParser:
|
||||
"""Get the argument parser."""
|
||||
parser = argparse.ArgumentParser(description='Run the agent via CLI')
|
||||
|
||||
# Add version argument
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='store_true', help='Show version information'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config-file',
|
||||
type=str,
|
||||
default='config.toml',
|
||||
help='Path to the config file (default: config.toml in the current directory)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--directory',
|
||||
type=str,
|
||||
help='The working directory for the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--task',
|
||||
type=str,
|
||||
default='',
|
||||
help='The task for the agent to perform',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-f',
|
||||
'--file',
|
||||
type=str,
|
||||
help='Path to a file containing the task. Overrides -t if both are provided.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--agent-cls',
|
||||
default=OH_DEFAULT_AGENT,
|
||||
type=str,
|
||||
help='Name of the default agent to use',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--max-iterations',
|
||||
default=OH_MAX_ITERATIONS,
|
||||
type=int,
|
||||
help='The maximum number of iterations to run the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-b',
|
||||
'--max-budget-per-task',
|
||||
type=float,
|
||||
help='The maximum budget allowed per task, beyond which the agent will stop.',
|
||||
)
|
||||
# --eval configs are for evaluations only
|
||||
parser.add_argument(
|
||||
'--eval-output-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
type=str,
|
||||
help='The directory to save evaluation output',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-n-limit',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The number of instances to evaluate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-num-workers',
|
||||
default=4,
|
||||
type=int,
|
||||
help='The number of workers to use for evaluation',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-note',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The note to add to the evaluation directory',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--llm-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default LLM ([llm] section in config.toml) config with the specified LLM config, e.g. "llama3" for [llm.llama3] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--agent-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default Agent ([agent] section in config.toml) config with the specified Agent config, e.g. "CodeAct" for [agent.CodeAct] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
help='Session name',
|
||||
type=str,
|
||||
default='',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-ids',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The comma-separated list (in quotes) of IDs of the instances to evaluate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-auto-continue',
|
||||
help='Disable auto-continue responses in headless mode (i.e. headless will read from stdin instead of auto-continuing)',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--selected-repo',
|
||||
help='GitHub repository to clone (format: owner/repo)',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--override-cli-mode',
|
||||
help='Override the default settings for CLI mode',
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
help='Set the log level',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = get_parser()
|
||||
parser = get_headless_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
@@ -914,17 +778,17 @@ def setup_config_from_args(args: argparse.Namespace) -> OpenHandsConfig:
|
||||
)
|
||||
|
||||
# Override default agent if provided
|
||||
if args.agent_cls:
|
||||
if hasattr(args, 'agent_cls') and args.agent_cls:
|
||||
config.default_agent = args.agent_cls
|
||||
|
||||
# Set max iterations and max budget per task if provided, otherwise fall back to config values
|
||||
if args.max_iterations is not None:
|
||||
if hasattr(args, 'max_iterations') and args.max_iterations is not None:
|
||||
config.max_iterations = args.max_iterations
|
||||
if args.max_budget_per_task is not None:
|
||||
if hasattr(args, 'max_budget_per_task') and args.max_budget_per_task is not None:
|
||||
config.max_budget_per_task = args.max_budget_per_task
|
||||
|
||||
# Read selected repository in config for use by CLI and main.py
|
||||
if args.selected_repo is not None:
|
||||
if hasattr(args, 'selected_repo') and args.selected_repo is not None:
|
||||
config.sandbox.selected_repo = args.selected_repo
|
||||
|
||||
return config
|
||||
|
||||
@@ -383,7 +383,7 @@ Do NOT assume the environment is the same as in the example above.
|
||||
"""
|
||||
example = example.lstrip()
|
||||
|
||||
return example
|
||||
return refine_prompt(example)
|
||||
|
||||
|
||||
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools
|
||||
|
||||
@@ -63,6 +63,7 @@ CACHE_PROMPT_SUPPORTED_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
]
|
||||
|
||||
# function calling supporting models
|
||||
@@ -77,6 +78,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4o',
|
||||
'o1-2024-12-17',
|
||||
@@ -92,6 +94,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'kimi-k2-instruct',
|
||||
'Qwen3-Coder-480B-A35B-Instruct',
|
||||
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
|
||||
'gpt-5-2025-08-07',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
@@ -105,6 +108,7 @@ REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5-2025-08-07',
|
||||
]
|
||||
|
||||
MODELS_WITHOUT_STOP_WORDS = [
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.memory.memory import Memory
|
||||
|
||||
|
||||
from mcp import McpError
|
||||
@@ -20,7 +21,6 @@ from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.mcp.client import MCPClient
|
||||
from openhands.mcp.error_collector import mcp_error_collector
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
|
||||
|
||||
@@ -676,9 +676,7 @@ class ActionExecutor:
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.warning('Starting Action Execution Server')
|
||||
logger.warning('Arguments passed to script:')
|
||||
for i, arg in enumerate(sys.argv):
|
||||
logger.warning(f'Argument {i}: {arg}')
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('port', type=int, help='Port to listen on')
|
||||
parser.add_argument('--working-dir', type=str, help='Working directory')
|
||||
|
||||
@@ -9,6 +9,7 @@ import docker
|
||||
import httpx
|
||||
import tenacity
|
||||
from docker.models.containers import Container
|
||||
from docker.types import DriverConfig, Mount
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.exceptions import (
|
||||
@@ -258,6 +259,9 @@ class DockerRuntime(ActionExecutionClient):
|
||||
container_path = parts[1]
|
||||
# Default mode is 'rw' if not specified
|
||||
mount_mode = parts[2] if len(parts) > 2 else 'rw'
|
||||
# Skip overlay mounts here; they will be handled separately via Mount objects
|
||||
if 'overlay' in mount_mode:
|
||||
continue
|
||||
|
||||
volumes[host_path] = {
|
||||
'bind': container_path,
|
||||
@@ -286,6 +290,72 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
return volumes
|
||||
|
||||
def _process_overlay_mounts(self) -> list[Mount]:
|
||||
"""Process overlay mounts specified in sandbox.volumes with mode containing 'overlay'.
|
||||
|
||||
Returns:
|
||||
List of docker.types.Mount objects configured with overlay driver providing
|
||||
read-only lowerdir with per-container copy-on-write upper/work layers.
|
||||
"""
|
||||
overlay_mounts: list[Mount] = []
|
||||
|
||||
# No volumes configured
|
||||
if self.config.sandbox.volumes is None:
|
||||
return overlay_mounts
|
||||
|
||||
# Base directory for overlay upper/work layers from env var
|
||||
overlay_base = os.environ.get('SANDBOX_VOLUME_OVERLAYS')
|
||||
if not overlay_base:
|
||||
# If no base path provided, skip overlay processing
|
||||
return overlay_mounts
|
||||
|
||||
os.makedirs(overlay_base, exist_ok=True)
|
||||
|
||||
mount_specs = self.config.sandbox.volumes.split(',')
|
||||
|
||||
for idx, mount_spec in enumerate(mount_specs):
|
||||
parts = mount_spec.split(':')
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
host_path = os.path.abspath(parts[0])
|
||||
container_path = parts[1]
|
||||
mount_mode = parts[2] if len(parts) > 2 else 'rw'
|
||||
|
||||
if 'overlay' not in mount_mode:
|
||||
continue
|
||||
|
||||
# Prepare upper and work directories unique to this container and mount
|
||||
overlay_dir = os.path.join(overlay_base, self.container_name, f'{idx}')
|
||||
upper_dir = os.path.join(overlay_dir, 'upper')
|
||||
work_dir = os.path.join(overlay_dir, 'work')
|
||||
os.makedirs(upper_dir, exist_ok=True)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
|
||||
driver_cfg = DriverConfig(
|
||||
name='local',
|
||||
options={
|
||||
'type': 'overlay',
|
||||
'device': 'overlay',
|
||||
'o': f'lowerdir={host_path},upperdir={upper_dir},workdir={work_dir}',
|
||||
},
|
||||
)
|
||||
|
||||
mount = Mount(
|
||||
target=container_path,
|
||||
source='', # Anonymous volume
|
||||
type='volume',
|
||||
labels={
|
||||
'app': 'openhands',
|
||||
'role': 'worker',
|
||||
'container': self.container_name,
|
||||
},
|
||||
driver_config=driver_cfg,
|
||||
)
|
||||
|
||||
overlay_mounts.append(mount)
|
||||
|
||||
return overlay_mounts
|
||||
|
||||
def init_container(self) -> None:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
@@ -409,6 +479,9 @@ class DockerRuntime(ActionExecutionClient):
|
||||
try:
|
||||
if self.runtime_container_image is None:
|
||||
raise ValueError('Runtime container image is not set')
|
||||
# Process overlay mounts (read-only lower with per-container COW)
|
||||
overlay_mounts = self._process_overlay_mounts()
|
||||
|
||||
self.container = self.docker_client.containers.run(
|
||||
self.runtime_container_image,
|
||||
command=command,
|
||||
@@ -421,6 +494,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
detach=True,
|
||||
environment=environment,
|
||||
volumes=volumes, # type: ignore
|
||||
mounts=overlay_mounts, # type: ignore
|
||||
device_requests=device_requests,
|
||||
**(self.config.sandbox.docker_runtime_kwargs or {}),
|
||||
)
|
||||
@@ -609,7 +683,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the runtime by stopping the container.
|
||||
This is different from container.stop() as it ensures environment variables are properly preserved."""
|
||||
This is different from container.stop() as it ensures environment variables are properly preserved.
|
||||
"""
|
||||
if not self.container:
|
||||
raise RuntimeError('Container not initialized')
|
||||
|
||||
@@ -622,7 +697,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the runtime by starting the container.
|
||||
This is different from container.start() as it ensures environment variables are properly restored."""
|
||||
This is different from container.start() as it ensures environment variables are properly restored.
|
||||
"""
|
||||
if not self.container:
|
||||
raise RuntimeError('Container not initialized')
|
||||
|
||||
|
||||
@@ -49,124 +49,72 @@ def init_user_and_working_directory(
|
||||
if username == os.getenv('USER') and username not in ['root', 'openhands']:
|
||||
return None
|
||||
|
||||
# Skip root since it is already created
|
||||
if username != 'root':
|
||||
# Check if the username already exists
|
||||
logger.info(f'Attempting to create user `{username}` with UID {user_id}.')
|
||||
existing_user_id = -1
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
return existing_user_id
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.info(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'Error checking user `{username}`, skipping setup:\n{e}\n'
|
||||
)
|
||||
raise
|
||||
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
|
||||
# First create the working directory, independent of the user
|
||||
logger.debug(f'Client working directory: {initial_cwd}')
|
||||
command = f'umask 002; mkdir -p {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str = output.stdout.decode()
|
||||
logger.debug(f'mkdir command result: returncode={output.returncode}, stdout=[{out_str}], stderr=[{output.stderr.decode()}]')
|
||||
|
||||
# Check current ownership before changing it
|
||||
check_cmd = f'ls -la {initial_cwd}'
|
||||
check_output = subprocess.run(check_cmd, shell=True, capture_output=True)
|
||||
logger.debug(f'Current ownership: {check_output.stdout.decode()}')
|
||||
|
||||
# Check if we're running as root
|
||||
whoami_output = subprocess.run('whoami', shell=True, capture_output=True)
|
||||
current_user = whoami_output.stdout.decode().strip()
|
||||
logger.debug(f'Current user: {current_user}')
|
||||
|
||||
# Use sudo only if not running as root
|
||||
sudo_prefix = '' if current_user == 'root' else 'sudo '
|
||||
|
||||
command = f'{sudo_prefix}chown -R {username}:{username} {initial_cwd}'
|
||||
logger.debug(f'Executing chown command: {command}')
|
||||
command = f'chown -R {username}:root {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str += output.stdout.decode()
|
||||
logger.debug(f'chown command result: returncode={output.returncode}, stdout=[{output.stdout.decode()}], stderr=[{output.stderr.decode()}]')
|
||||
if output.returncode != 0 or output.stderr:
|
||||
err_str = output.stderr.decode()
|
||||
logger.error(f'chown command failed: returncode={output.returncode}, stderr: {err_str}')
|
||||
out_str += f' [stderr: {err_str}]'
|
||||
|
||||
command = f'{sudo_prefix}chmod g+rw {initial_cwd}'
|
||||
logger.debug(f'Executing chmod command: {command}')
|
||||
command = f'chmod g+rw {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str += output.stdout.decode()
|
||||
logger.debug(f'chmod command result: returncode={output.returncode}, stdout=[{output.stdout.decode()}], stderr=[{output.stderr.decode()}]')
|
||||
if output.returncode != 0 or output.stderr:
|
||||
err_str = output.stderr.decode()
|
||||
logger.error(f'chmod command failed: returncode={output.returncode}, stderr: {err_str}')
|
||||
out_str += f' [stderr: {err_str}]'
|
||||
|
||||
# Verify final ownership
|
||||
check_cmd = f'ls -la {initial_cwd}'
|
||||
check_output = subprocess.run(check_cmd, shell=True, capture_output=True)
|
||||
final_ownership = check_output.stdout.decode()
|
||||
logger.debug(f'Final ownership: {final_ownership}')
|
||||
|
||||
# If chown failed and directory is still owned by root, try alternative approaches
|
||||
if 'root root' in final_ownership and username != 'root':
|
||||
logger.warning(f'Directory {initial_cwd} is still owned by root, trying alternative approaches')
|
||||
|
||||
# Try to make it writable for the user's group
|
||||
alt_command = f'{sudo_prefix}chmod -R g+rwx {initial_cwd}'
|
||||
logger.debug(f'Executing alternative chmod command: {alt_command}')
|
||||
alt_output = subprocess.run(alt_command, shell=True, capture_output=True)
|
||||
logger.debug(f'Alternative chmod result: returncode={alt_output.returncode}, stderr=[{alt_output.stderr.decode()}]')
|
||||
|
||||
# Try to add the user to the root group (as a last resort)
|
||||
if alt_output.returncode != 0:
|
||||
group_command = f'{sudo_prefix}usermod -aG root {username}'
|
||||
logger.debug(f'Executing usermod command: {group_command}')
|
||||
group_output = subprocess.run(group_command, shell=True, capture_output=True)
|
||||
logger.debug(f'Usermod result: returncode={group_output.returncode}, stderr=[{group_output.stderr.decode()}]')
|
||||
|
||||
logger.debug(f'Created working directory. Output: [{out_str}]')
|
||||
|
||||
# Skip root since it is already created
|
||||
if username == 'root':
|
||||
return None
|
||||
|
||||
# Check if the username already exists
|
||||
existing_user_id = -1
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
return existing_user_id
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.debug(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(f'Error checking user `{username}`, skipping setup:\n{e}\n')
|
||||
raise
|
||||
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -56,8 +56,16 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/
|
||||
# Add /openhands/bin to PATH
|
||||
ENV PATH="/openhands/bin:${PATH}"
|
||||
|
||||
# Remove UID 1000 named pn or ubuntu, so the 'openhands' user can be created from ubuntu hosts
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi)
|
||||
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Define Docker installation macro
|
||||
@@ -103,29 +111,6 @@ RUN \
|
||||
# Configure Docker daemon with MTU 1450 to prevent packet fragmentation issues
|
||||
RUN mkdir -p /etc/docker && \
|
||||
echo '{"mtu": 1450}' > /etc/docker/daemon.json
|
||||
|
||||
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user
|
||||
RUN groupadd -g 1000 openhands && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry && \
|
||||
mkdir -p /workspace && \
|
||||
mkdir -p /workspace/.openhands && \
|
||||
mkdir -p /home/openhands/.openhands && \
|
||||
chown -R openhands:openhands /openhands && \
|
||||
chown -R openhands:openhands /workspace && \
|
||||
chown -R openhands:openhands /home/openhands
|
||||
{% endmacro %}
|
||||
|
||||
# Install Docker only if not a swebench or mswebench image
|
||||
@@ -165,8 +150,7 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
|
||||
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
|
||||
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
|
||||
|
||||
|
||||
|
||||
@@ -175,12 +159,10 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
{% macro install_vscode_extensions() %}
|
||||
# Install our custom extension
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/ && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
|
||||
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/ && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
|
||||
|
||||
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
|
||||
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
|
||||
@@ -203,12 +185,9 @@ RUN \
|
||||
{% endif %}
|
||||
# Set environment variables
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Set permissions and ownership
|
||||
# Set permissions
|
||||
chmod -R g+rws /openhands/poetry && \
|
||||
chown -R openhands:openhands /openhands/poetry && \
|
||||
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/micromamba && \
|
||||
# Clean up
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
@@ -229,8 +208,7 @@ RUN \
|
||||
RUN mkdir -p /openhands/micromamba/bin && \
|
||||
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
|
||||
/openhands/micromamba/bin/micromamba config remove channels defaults && \
|
||||
/openhands/micromamba/bin/micromamba config list && \
|
||||
chown -R openhands:openhands /openhands/micromamba
|
||||
/openhands/micromamba/bin/micromamba config list
|
||||
|
||||
# Create the openhands virtual environment and install poetry and python
|
||||
RUN /openhands/micromamba/bin/micromamba create -n openhands -y && \
|
||||
@@ -241,12 +219,11 @@ RUN \
|
||||
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
|
||||
mkdir -p /openhands/code/openhands && \
|
||||
touch /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code && \
|
||||
# Set global git configuration to ensure proper author/committer information
|
||||
git config --global user.name "openhands" && \
|
||||
git config --global user.email "openhands@all-hands.dev"
|
||||
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
{{ install_dependencies() }}
|
||||
|
||||
@@ -257,43 +234,14 @@ COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openh
|
||||
|
||||
{{ setup_vscode_server() }}
|
||||
|
||||
# ================================================================
|
||||
# Ensure openhands user and directories exist (for non-scratch builds)
|
||||
# ================================================================
|
||||
{% if not build_from_scratch %}
|
||||
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user if they don't exist
|
||||
RUN (getent group openhands || groupadd -g 1000 openhands) && \
|
||||
(getent passwd openhands || useradd -u 1000 -g 1000 -m -s /bin/bash openhands) && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
# Create necessary directories and set ownership
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry && \
|
||||
mkdir -p /workspace && \
|
||||
mkdir -p /workspace/.openhands && \
|
||||
mkdir -p /home/openhands/.openhands && \
|
||||
chown -R openhands:openhands /openhands && \
|
||||
chown -R openhands:openhands /workspace && \
|
||||
chown -R openhands:openhands /home/openhands
|
||||
{% endif %}
|
||||
|
||||
# ================================================================
|
||||
# Copy Project source files
|
||||
# ================================================================
|
||||
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code
|
||||
COPY ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py
|
||||
|
||||
|
||||
|
||||
@@ -307,12 +255,3 @@ RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
|
||||
# Install extra dependencies if specified
|
||||
{% if extra_deps %}RUN {{ extra_deps }} {% endif %}
|
||||
|
||||
# Copy entrypoint script and make it executable
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Set the entrypoint to run as root first, then switch to openhands
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# Note: We don't set USER openhands here because the entrypoint handles the user switch
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# This entrypoint script runs as root to fix workspace ownership before switching to openhands user
|
||||
|
||||
echo "🔧 OpenHands Runtime Entrypoint - Fixing workspace ownership..."
|
||||
|
||||
# Check if /workspace exists and fix ownership
|
||||
if [ -d "/workspace" ]; then
|
||||
echo "📁 Found /workspace directory, checking ownership..."
|
||||
ls -la /workspace
|
||||
|
||||
# Fix ownership to openhands:openhands
|
||||
echo "🔧 Changing ownership to openhands:openhands..."
|
||||
chown -R openhands:openhands /workspace
|
||||
chmod -R g+rw /workspace
|
||||
|
||||
echo "✅ Ownership fixed:"
|
||||
ls -la /workspace
|
||||
else
|
||||
echo "⚠️ /workspace directory not found, will be created later"
|
||||
fi
|
||||
|
||||
# If arguments are provided, execute them as the openhands user
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "🚀 Switching to openhands user and executing: $@"
|
||||
# Use exec to replace the current process and preserve all arguments
|
||||
exec su openhands -c "exec \"\$@\"" -- "$@"
|
||||
else
|
||||
echo "🚀 Switching to openhands user with bash shell"
|
||||
exec su - openhands
|
||||
fi
|
||||
@@ -10,17 +10,18 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
ChangeAgentStateAction,
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
NullObservation,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
@@ -44,11 +45,11 @@ from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_convo_settings,
|
||||
)
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
file_store,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.user_auth import (
|
||||
@@ -60,7 +61,7 @@ from openhands.server.user_auth import (
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
from openhands.server.utils import get_conversation as get_conversation_object
|
||||
from openhands.server.utils import get_conversation as get_conversation_metadata
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
@@ -87,6 +88,7 @@ class InitSessionRequest(BaseModel):
|
||||
suggested_task: SuggestedTask | None = None
|
||||
create_microagent: CreateMicroagent | None = None
|
||||
conversation_instructions: str | None = None
|
||||
mcp_config: MCPConfig | None = None
|
||||
# Only nested runtimes require the ability to specify a conversation id, and it could be a security risk
|
||||
if os.getenv('ALLOW_SET_CONVERSATION_ID', '0') == '1':
|
||||
conversation_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||
@@ -178,6 +180,7 @@ async def new_conversation(
|
||||
conversation_instructions=conversation_instructions,
|
||||
git_provider=git_provider,
|
||||
conversation_id=conversation_id,
|
||||
mcp_config=data.mcp_config,
|
||||
)
|
||||
|
||||
return ConversationResponse(
|
||||
@@ -331,23 +334,20 @@ async def delete_conversation(
|
||||
return True
|
||||
|
||||
|
||||
@app.get('/conversations/{conversation_id}/remember_prompt')
|
||||
@app.get('/conversations/{conversation_id}/remember-prompt')
|
||||
async def get_prompt(
|
||||
conversation_id: str,
|
||||
event_id: int,
|
||||
user_settings: SettingsStore = Depends(get_user_settings_store),
|
||||
conversation: ServerConversation | None = Depends(get_conversation_object),
|
||||
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||
):
|
||||
if conversation is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={'error': 'Conversation not found.'},
|
||||
)
|
||||
|
||||
# get event stream for the conversation
|
||||
event_stream = conversation.event_stream
|
||||
# get event store for the conversation
|
||||
event_store = EventStore(
|
||||
sid=conversation_id, file_store=file_store, user_id=metadata.user_id
|
||||
)
|
||||
|
||||
# retrieve the relevant events
|
||||
stringified_events = _get_contextual_events(event_stream, event_id)
|
||||
stringified_events = _get_contextual_events(event_store, event_id)
|
||||
|
||||
# generate a prompt
|
||||
settings = await user_settings.load()
|
||||
@@ -551,7 +551,7 @@ async def stop_conversation(
|
||||
)
|
||||
|
||||
|
||||
def _get_contextual_events(event_stream: EventStream, event_id: int) -> str:
|
||||
def _get_contextual_events(event_store: EventStore, event_id: int) -> str:
|
||||
# find the specified events to learn from
|
||||
# Get X events around the target event
|
||||
context_size = 4
|
||||
@@ -567,7 +567,7 @@ def _get_contextual_events(event_stream: EventStream, event_id: int) -> str:
|
||||
) # the types of events that can be in an agent's history
|
||||
|
||||
# from event_id - context_size to event_id..
|
||||
context_before = event_stream.search_events(
|
||||
context_before = event_store.search_events(
|
||||
start_id=event_id,
|
||||
filter=agent_event_filter,
|
||||
reverse=True,
|
||||
@@ -575,7 +575,7 @@ def _get_contextual_events(event_stream: EventStream, event_id: int) -> str:
|
||||
)
|
||||
|
||||
# from event_id to event_id + context_size + 1
|
||||
context_after = event_stream.search_events(
|
||||
context_after = event_store.search_events(
|
||||
start_id=event_id + 1,
|
||||
filter=agent_event_filter,
|
||||
limit=context_size + 1,
|
||||
|
||||
@@ -2,6 +2,7 @@ import uuid
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
@@ -44,6 +45,7 @@ async def create_new_conversation(
|
||||
attach_convo_id: bool = False,
|
||||
git_provider: ProviderType | None = None,
|
||||
conversation_id: str | None = None,
|
||||
mcp_config: MCPConfig | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
logger.info(
|
||||
'Creating conversation',
|
||||
@@ -82,6 +84,9 @@ async def create_new_conversation(
|
||||
session_init_args['selected_branch'] = selected_branch
|
||||
session_init_args['git_provider'] = git_provider
|
||||
session_init_args['conversation_instructions'] = conversation_instructions
|
||||
if mcp_config:
|
||||
session_init_args['mcp_config'] = mcp_config
|
||||
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
|
||||
logger.info('Loading conversation store')
|
||||
|
||||
@@ -124,10 +124,12 @@ class Session:
|
||||
)
|
||||
|
||||
# Set Git user configuration if provided in settings
|
||||
if hasattr(settings, 'git_user_name') and settings.git_user_name:
|
||||
self.config.git_user_name = settings.git_user_name
|
||||
if hasattr(settings, 'git_user_email') and settings.git_user_email:
|
||||
self.config.git_user_email = settings.git_user_email
|
||||
git_user_name = getattr(settings, 'git_user_name', None)
|
||||
if git_user_name is not None:
|
||||
self.config.git_user_name = git_user_name
|
||||
git_user_email = getattr(settings, 'git_user_email', None)
|
||||
if git_user_email is not None:
|
||||
self.config.git_user_email = git_user_email
|
||||
max_iterations = settings.max_iterations or self.config.max_iterations
|
||||
|
||||
# Prioritize settings over config for max_budget_per_task
|
||||
@@ -152,6 +154,14 @@ class Session:
|
||||
self.logger.debug(
|
||||
f'MCP configuration before setup - self.config.mcp_config: {self.config.mcp}'
|
||||
)
|
||||
|
||||
# Check if settings has custom mcp_config
|
||||
mcp_config = getattr(settings, 'mcp_config', None)
|
||||
if mcp_config is not None:
|
||||
# Use the provided MCP SHTTP servers instead of default setup
|
||||
self.config.mcp = self.config.mcp.merge(mcp_config)
|
||||
self.logger.debug(f'Merged custom MCP Config: {mcp_config}')
|
||||
|
||||
# Add OpenHands' MCP server by default
|
||||
openhands_mcp_server, openhands_mcp_stdio_servers = (
|
||||
OpenHandsMCPConfigImpl.create_default_mcp_server_config(
|
||||
@@ -163,7 +173,7 @@ class Session:
|
||||
self.config.mcp.shttp_servers.append(openhands_mcp_server)
|
||||
self.logger.debug('Added default MCP HTTP server to config')
|
||||
|
||||
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
|
||||
self.logger.debug(
|
||||
f'MCP configuration after setup - self.config.mcp: {self.config.mcp}'
|
||||
|
||||
@@ -27,10 +27,11 @@ assert isinstance(server_config_interface, ServerConfig), (
|
||||
)
|
||||
server_config: ServerConfig = server_config_interface
|
||||
file_store: FileStore = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
|
||||
client_manager = None
|
||||
|
||||
@@ -61,9 +61,12 @@ The `WebHookFileStore` wraps another `FileStore` implementation and sends HTTP r
|
||||
**Configuration Options:**
|
||||
- `file_store_web_hook_url`: The base URL for webhook requests
|
||||
- `file_store_web_hook_headers`: HTTP headers to include in webhook requests
|
||||
- `file_store_web_hook_batch`: Whether to use batched webhook requests (default: false)
|
||||
|
||||
### Protocol Details
|
||||
|
||||
#### Standard Webhook Protocol (Non-Batched)
|
||||
|
||||
1. **File Write Operation**:
|
||||
- When a file is written, a POST request is sent to `{base_url}{path}`
|
||||
- The request body contains the file contents
|
||||
@@ -73,6 +76,27 @@ The `WebHookFileStore` wraps another `FileStore` implementation and sends HTTP r
|
||||
- When a file is deleted, a DELETE request is sent to `{base_url}{path}`
|
||||
- The operation is retried up to 3 times with a 1-second delay between attempts
|
||||
|
||||
#### Batched Webhook Protocol
|
||||
|
||||
The `BatchedWebHookFileStore` extends the webhook functionality by batching multiple file operations into a single request, which can significantly improve performance when many files are being modified in a short period of time.
|
||||
|
||||
1. **Batch Request**:
|
||||
- A single POST request is sent to `{base_url}` with a JSON array in the body
|
||||
- Each item in the array contains:
|
||||
- `method`: "POST" for write operations, "DELETE" for delete operations
|
||||
- `path`: The file path
|
||||
- `content`: The file contents (for write operations only)
|
||||
- `encoding`: "base64" if binary content was base64-encoded (optional)
|
||||
|
||||
2. **Batch Triggering**:
|
||||
- Batches are sent when one of the following conditions is met:
|
||||
- A timeout period has elapsed (defaults to 5 seconds, configurable via constructor parameter)
|
||||
- The total size of batched content exceeds a size limit (defaults to 1MB, configurable via constructor parameter)
|
||||
- The `flush()` method is explicitly called
|
||||
|
||||
3. **Error Handling**:
|
||||
- The batch request is retried up to 3 times with a 1-second delay between attempts
|
||||
|
||||
## Configuration
|
||||
|
||||
To configure the storage module in OpenHands, use the following configuration options:
|
||||
@@ -90,4 +114,14 @@ file_store_web_hook_url = "https://example.com/api/files"
|
||||
|
||||
# Optional webhook headers (JSON string)
|
||||
file_store_web_hook_headers = '{"Authorization": "Bearer token"}'
|
||||
|
||||
# Optional batched webhook mode (default: false)
|
||||
file_store_web_hook_batch = true
|
||||
```
|
||||
|
||||
**Batched Webhook Configuration:**
|
||||
The batched webhook behavior uses predefined constants with the following default values:
|
||||
- Batch timeout: 5 seconds
|
||||
- Batch size limit: 1MB (1048576 bytes)
|
||||
|
||||
These values can be customized by passing `batch_timeout_seconds` and `batch_size_limit_bytes` parameters to the `BatchedWebHookFileStore` constructor.
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.storage.batched_web_hook import BatchedWebHookFileStore
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.google_cloud import GoogleCloudFileStore
|
||||
from openhands.storage.local import LocalFileStore
|
||||
@@ -15,6 +16,7 @@ def get_file_store(
|
||||
file_store_path: str | None = None,
|
||||
file_store_web_hook_url: str | None = None,
|
||||
file_store_web_hook_headers: dict | None = None,
|
||||
file_store_web_hook_batch: bool = False,
|
||||
) -> FileStore:
|
||||
store: FileStore
|
||||
if file_store_type == 'local':
|
||||
@@ -35,9 +37,21 @@ def get_file_store(
|
||||
file_store_web_hook_headers['X-Session-API-Key'] = os.getenv(
|
||||
'SESSION_API_KEY'
|
||||
)
|
||||
store = WebHookFileStore(
|
||||
store,
|
||||
file_store_web_hook_url,
|
||||
httpx.Client(headers=file_store_web_hook_headers or {}),
|
||||
)
|
||||
|
||||
client = httpx.Client(headers=file_store_web_hook_headers or {})
|
||||
|
||||
if file_store_web_hook_batch:
|
||||
# Use batched webhook file store
|
||||
store = BatchedWebHookFileStore(
|
||||
store,
|
||||
file_store_web_hook_url,
|
||||
client,
|
||||
)
|
||||
else:
|
||||
# Use regular webhook file store
|
||||
store = WebHookFileStore(
|
||||
store,
|
||||
file_store_web_hook_url,
|
||||
client,
|
||||
)
|
||||
return store
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import threading
|
||||
from typing import Optional, Union
|
||||
|
||||
import httpx
|
||||
import tenacity
|
||||
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.async_utils import EXECUTOR
|
||||
|
||||
# Constants for batching configuration
|
||||
WEBHOOK_BATCH_TIMEOUT_SECONDS = 5.0
|
||||
WEBHOOK_BATCH_SIZE_LIMIT_BYTES = 1048576 # 1MB
|
||||
|
||||
|
||||
class BatchedWebHookFileStore(FileStore):
|
||||
"""
|
||||
File store which batches updates before sending them to a webhook.
|
||||
|
||||
This class wraps another FileStore implementation and sends HTTP requests
|
||||
to a specified URL when files are written or deleted. Updates are batched
|
||||
and sent together after a certain amount of time passes or if the content
|
||||
size exceeds a threshold.
|
||||
|
||||
Attributes:
|
||||
file_store: The underlying FileStore implementation
|
||||
base_url: The base URL for webhook requests
|
||||
client: The HTTP client used to make webhook requests
|
||||
batch_timeout_seconds: Time in seconds after which a batch is sent (default: WEBHOOK_BATCH_TIMEOUT_SECONDS)
|
||||
batch_size_limit_bytes: Size limit in bytes after which a batch is sent (default: WEBHOOK_BATCH_SIZE_LIMIT_BYTES)
|
||||
_batch_lock: Lock for thread-safe access to the batch
|
||||
_batch: Dictionary of pending file updates
|
||||
_batch_timer: Timer for sending batches after timeout
|
||||
_batch_size: Current size of the batch in bytes
|
||||
"""
|
||||
|
||||
file_store: FileStore
|
||||
base_url: str
|
||||
client: httpx.Client
|
||||
batch_timeout_seconds: float
|
||||
batch_size_limit_bytes: int
|
||||
_batch_lock: threading.Lock
|
||||
_batch: dict[str, tuple[str, Optional[Union[str, bytes]]]]
|
||||
_batch_timer: Optional[threading.Timer]
|
||||
_batch_size: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_store: FileStore,
|
||||
base_url: str,
|
||||
client: Optional[httpx.Client] = None,
|
||||
batch_timeout_seconds: Optional[float] = None,
|
||||
batch_size_limit_bytes: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a BatchedWebHookFileStore.
|
||||
|
||||
Args:
|
||||
file_store: The underlying FileStore implementation
|
||||
base_url: The base URL for webhook requests
|
||||
client: Optional HTTP client to use for requests. If None, a new client will be created.
|
||||
batch_timeout_seconds: Time in seconds after which a batch is sent.
|
||||
If None, uses the default constant WEBHOOK_BATCH_TIMEOUT_SECONDS.
|
||||
batch_size_limit_bytes: Size limit in bytes after which a batch is sent.
|
||||
If None, uses the default constant WEBHOOK_BATCH_SIZE_LIMIT_BYTES.
|
||||
"""
|
||||
self.file_store = file_store
|
||||
self.base_url = base_url
|
||||
if client is None:
|
||||
client = httpx.Client()
|
||||
self.client = client
|
||||
|
||||
# Use provided values or default constants
|
||||
self.batch_timeout_seconds = (
|
||||
batch_timeout_seconds or WEBHOOK_BATCH_TIMEOUT_SECONDS
|
||||
)
|
||||
self.batch_size_limit_bytes = (
|
||||
batch_size_limit_bytes or WEBHOOK_BATCH_SIZE_LIMIT_BYTES
|
||||
)
|
||||
|
||||
# Initialize batch state
|
||||
self._batch_lock = threading.Lock()
|
||||
self._batch = {} # Maps path -> (operation, content)
|
||||
self._batch_timer = None
|
||||
self._batch_size = 0
|
||||
|
||||
def write(self, path: str, contents: Union[str, bytes]) -> None:
|
||||
"""
|
||||
Write contents to a file and queue a webhook update.
|
||||
|
||||
Args:
|
||||
path: The path to write to
|
||||
contents: The contents to write
|
||||
"""
|
||||
self.file_store.write(path, contents)
|
||||
self._queue_update(path, 'write', contents)
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
"""
|
||||
Read contents from a file.
|
||||
|
||||
Args:
|
||||
path: The path to read from
|
||||
|
||||
Returns:
|
||||
The contents of the file
|
||||
"""
|
||||
return self.file_store.read(path)
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
"""
|
||||
List files in a directory.
|
||||
|
||||
Args:
|
||||
path: The directory path to list
|
||||
|
||||
Returns:
|
||||
A list of file paths
|
||||
"""
|
||||
return self.file_store.list(path)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
"""
|
||||
Delete a file and queue a webhook update.
|
||||
|
||||
Args:
|
||||
path: The path to delete
|
||||
"""
|
||||
self.file_store.delete(path)
|
||||
self._queue_update(path, 'delete', None)
|
||||
|
||||
def _queue_update(
|
||||
self, path: str, operation: str, contents: Optional[Union[str, bytes]]
|
||||
) -> None:
|
||||
"""
|
||||
Queue an update to be sent to the webhook.
|
||||
|
||||
Args:
|
||||
path: The path that was modified
|
||||
operation: The operation performed ("write" or "delete")
|
||||
contents: The contents that were written (None for delete operations)
|
||||
"""
|
||||
with self._batch_lock:
|
||||
# Calculate content size
|
||||
content_size = 0
|
||||
if contents is not None:
|
||||
if isinstance(contents, str):
|
||||
content_size = len(contents.encode('utf-8'))
|
||||
else:
|
||||
content_size = len(contents)
|
||||
|
||||
# Update batch size calculation
|
||||
# If this path already exists in the batch, subtract its previous size
|
||||
if path in self._batch:
|
||||
prev_op, prev_contents = self._batch[path]
|
||||
if prev_contents is not None:
|
||||
if isinstance(prev_contents, str):
|
||||
self._batch_size -= len(prev_contents.encode('utf-8'))
|
||||
else:
|
||||
self._batch_size -= len(prev_contents)
|
||||
|
||||
# Add new content size
|
||||
self._batch_size += content_size
|
||||
|
||||
# Add to batch
|
||||
self._batch[path] = (operation, contents)
|
||||
|
||||
# Check if we need to send the batch due to size limit
|
||||
if self._batch_size >= self.batch_size_limit_bytes:
|
||||
# Submit to executor to avoid blocking
|
||||
EXECUTOR.submit(self._send_batch)
|
||||
return
|
||||
|
||||
# Start or reset the timer for sending the batch
|
||||
if self._batch_timer is not None:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
timer = threading.Timer(
|
||||
self.batch_timeout_seconds, self._send_batch_from_timer
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self._batch_timer = timer
|
||||
|
||||
def _send_batch_from_timer(self) -> None:
|
||||
"""
|
||||
Send the batch from the timer thread.
|
||||
This method is called by the timer and submits the actual sending to the executor.
|
||||
"""
|
||||
EXECUTOR.submit(self._send_batch)
|
||||
|
||||
def _send_batch(self) -> None:
|
||||
"""
|
||||
Send the current batch of updates to the webhook as a single request.
|
||||
This method acquires the batch lock and processes all pending updates in one batch.
|
||||
"""
|
||||
batch_to_send: dict[str, tuple[str, Optional[Union[str, bytes]]]] = {}
|
||||
|
||||
with self._batch_lock:
|
||||
if not self._batch:
|
||||
return
|
||||
|
||||
# Copy the batch and clear the current one
|
||||
batch_to_send = self._batch.copy()
|
||||
self._batch.clear()
|
||||
self._batch_size = 0
|
||||
|
||||
# Cancel any pending timer
|
||||
if self._batch_timer is not None:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
# Process the entire batch in a single request
|
||||
if batch_to_send:
|
||||
try:
|
||||
self._send_batch_request(batch_to_send)
|
||||
except Exception as e:
|
||||
# Log the error
|
||||
print(f'Error sending webhook batch: {e}')
|
||||
|
||||
@tenacity.retry(
|
||||
wait=tenacity.wait_fixed(1),
|
||||
stop=tenacity.stop_after_attempt(3),
|
||||
)
|
||||
def _send_batch_request(
|
||||
self, batch: dict[str, tuple[str, Optional[Union[str, bytes]]]]
|
||||
) -> None:
|
||||
"""
|
||||
Send a single batch request to the webhook URL with all updates.
|
||||
|
||||
This method is retried up to 3 times with a 1-second delay between attempts.
|
||||
|
||||
Args:
|
||||
batch: Dictionary mapping paths to (operation, contents) tuples
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the webhook request fails
|
||||
"""
|
||||
# Prepare the batch payload
|
||||
batch_payload = []
|
||||
|
||||
for path, (operation, contents) in batch.items():
|
||||
item = {
|
||||
'method': 'POST' if operation == 'write' else 'DELETE',
|
||||
'path': path,
|
||||
}
|
||||
|
||||
if operation == 'write' and contents is not None:
|
||||
# Convert bytes to string if needed
|
||||
if isinstance(contents, bytes):
|
||||
try:
|
||||
# Try to decode as UTF-8
|
||||
item['content'] = contents.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
# If not UTF-8, use base64 encoding
|
||||
import base64
|
||||
|
||||
item['content'] = base64.b64encode(contents).decode('ascii')
|
||||
item['encoding'] = 'base64'
|
||||
else:
|
||||
item['content'] = contents
|
||||
|
||||
batch_payload.append(item)
|
||||
|
||||
# Send the batch as a single request
|
||||
response = self.client.post(self.base_url, json=batch_payload)
|
||||
response.raise_for_status()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""
|
||||
Immediately send any pending updates to the webhook.
|
||||
This can be called to ensure all updates are sent before shutting down.
|
||||
"""
|
||||
self._send_batch()
|
||||
@@ -106,10 +106,11 @@ class FileConversationStore(ConversationStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> FileConversationStore:
|
||||
file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return FileConversationStore(file_store)
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ class FileSecretsStore(SecretsStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> FileSecretsStore:
|
||||
file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return FileSecretsStore(file_store)
|
||||
|
||||
@@ -34,9 +34,10 @@ class FileSettingsStore(SettingsStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> FileSettingsStore:
|
||||
file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return FileSettingsStore(file_store)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user