diff --git a/.openhands/microagents/repo.md b/.openhands/microagents/repo.md index 43c0eaeccb68..9b6c76d3dea4 100644 --- a/.openhands/microagents/repo.md +++ b/.openhands/microagents/repo.md @@ -1,6 +1,7 @@ --- name: repo -agent: CodeAct +type: repo +agent: CodeActAgent --- This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend (in the `openhands` directory) and React frontend (in the `frontend` directory). diff --git a/Development.md b/Development.md index fbdaac497e91..2698a33d4bfb 100644 --- a/Development.md +++ b/Development.md @@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image. -Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.17-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.18-nikolaik` ## Develop inside Docker container diff --git a/README.md b/README.md index f14ff2e091ff..524378be90a6 100644 --- a/README.md +++ b/README.md @@ -137,17 +137,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu system requirements and more information. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 + docker.all-hands.dev/all-hands-ai/openhands:0.18 ``` You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)! @@ -158,16 +158,16 @@ works best, but you have [many options](https://docs.all-hands.dev/modules/usage --- -You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes), +You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem), run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), -or run it on tagged issues with [a github action](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md). +or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action). Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions. > [!CAUTION] > OpenHands is meant to be run by a single user on their local workstation. -> It is not appropriate for multi-tenant deployments, where multiple users share the same instance--there is no built-in isolation or scalability. +> It is not appropriate for multi-tenant deployments where multiple users share the same instance. There is no built-in isolation or scalability. > > If you're interested in running OpenHands in a multi-tenant environment, please > [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform) @@ -180,7 +180,7 @@ Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/us ## 📖 Documentation To learn more about the project, and for tips on using OpenHands, -**check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-started)**. +check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-started). There you'll find resources on how to use different LLM providers, troubleshooting resources, and advanced configuration options. diff --git a/compose.yml b/compose.yml index 7c46a236ae1d..8e8dcd03d398 100644 --- a/compose.yml +++ b/compose.yml @@ -7,7 +7,7 @@ services: image: openhands:latest container_name: openhands-app-${DATE:-} environment: - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.17-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/config.template.toml b/config.template.toml index 26291976fd7e..5890c5f301ff 100644 --- a/config.template.toml +++ b/config.template.toml @@ -180,6 +180,12 @@ model = "gpt-4o" # https://docs.litellm.ai/docs/completion/token_usage #custom_tokenizer = "" +# Whether to use native tool calling if supported by the model. Can be true, false, or None by default, which chooses the model's default behavior based on the evaluation. +# ATTENTION: Based on evaluation, enabling native function calling may lead to worse results +# in some scenarios. Use with caution and consider testing with your specific use case. +# https://github.com/All-Hands-AI/OpenHands/pull/4711 +#native_tool_calling = None + [llm.gpt4o-mini] api_key = "your-api-key" model = "gpt-4o" diff --git a/containers/app/Dockerfile b/containers/app/Dockerfile index 4332c5f1a9a5..b50281360356 100644 --- a/containers/app/Dockerfile +++ b/containers/app/Dockerfile @@ -72,6 +72,7 @@ ENV VIRTUAL_ENV=/app/.venv \ COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} RUN playwright install --with-deps chromium +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 --chmod=770 ./openhands/agenthub ./openhands/agenthub diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index e393f23a1b5e..952e7d2d1090 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -11,7 +11,7 @@ services: - BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"} - SANDBOX_API_HOSTNAME=host.docker.internal # - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.17-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index 9156d7ac46ff..a5a7a2cae45f 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -61,7 +61,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 \ + docker.all-hands.dev/all-hands-ai/openhands:0.18 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 9d1172770549..130da64e78d7 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -56,6 +56,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 \ + docker.all-hands.dev/all-hands-ai/openhands:0.18 \ python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue ``` diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx index ddfef195b661..21cf973215ee 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -13,16 +13,16 @@ La façon la plus simple d'exécuter OpenHands est avec Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 + docker.all-hands.dev/all-hands-ai/openhands:0.18 ``` Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action). diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md index 67d054c4791f..c2853d0b0d74 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands. ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index e6760ee2d63b..615e1b23d8e8 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -59,7 +59,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 \ + docker.all-hands.dev/all-hands-ai/openhands:0.18 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index c38831e4a462..6dd7554893f8 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -57,6 +57,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 \ + docker.all-hands.dev/all-hands-ai/openhands:0.18 \ python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx index 6de97bfc3bc5..648ed6a76450 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -11,16 +11,16 @@ 在 Docker 中运行 OpenHands 是最简单的方式。 ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 + docker.all-hands.dev/all-hands-ai/openhands:0.18 ``` 你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md index c6a7fc29053c..51f726904791 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -11,7 +11,7 @@ ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/modules/usage/how-to/cli-mode.md b/docs/modules/usage/how-to/cli-mode.md index 95fc303a1809..fb256dfb420e 100644 --- a/docs/modules/usage/how-to/cli-mode.md +++ b/docs/modules/usage/how-to/cli-mode.md @@ -6,10 +6,9 @@ This mode is different from the [headless mode](headless-mode), which is non-int ## With Python -To start an interactive OpenHands session via the command line, follow these steps: +To start an interactive OpenHands session via the command line: 1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md). - 2. Run the following command: ```bash @@ -21,45 +20,32 @@ This command will start an interactive session where you can input tasks and rec You'll need to be sure to set your model, API key, and other settings via environment variables [or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml). - ## With Docker -To run OpenHands in CLI mode with Docker, follow these steps: +To run OpenHands in CLI mode with Docker: -1. Set `WORKSPACE_BASE` to the directory you want OpenHands to edit: +1. Set the following environmental variables in your terminal: -```bash -WORKSPACE_BASE=$(pwd)/workspace -``` - -2. Set `LLM_MODEL` to the model you want to use: - -```bash -LLM_MODEL="anthropic/claude-3-5-sonnet-20241022" - -``` - -3. Set `LLM_API_KEY` to your API key: - -```bash -LLM_API_KEY="sk_test_12345" -``` +* `WORKSPACE_BASE` to the directory you want OpenHands to edit (Ex: `export WORKSPACE_BASE=$(pwd)/workspace`). +* `LLM_MODEL` to the model to use (Ex: `export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"`). +* `LLM_API_KEY` to the API key (Ex: `export LLM_API_KEY="sk_test_12345"`). -4. Run the following Docker command: +2. Run the following Docker command: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ -e LLM_MODEL=$LLM_MODEL \ -v $WORKSPACE_BASE:/opt/workspace_base \ -v /var/run/docker.sock:/var/run/docker.sock \ + -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 \ + docker.all-hands.dev/all-hands-ai/openhands:0.18 \ python -m openhands.core.cli ``` diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md index 2d085d813c3c..1efc994b3913 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -7,12 +7,11 @@ This is different from [CLI Mode](cli-mode), which is interactive, and better fo ## With Python -To run OpenHands in headless mode with Python, -[follow the Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md), -and then run: - +To run OpenHands in headless mode with Python: +1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md). +2. Run the following command: ```bash -poetry run python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue +poetry run python -m openhands.core.main -t "write a bash script that prints hi" ``` You'll need to be sure to set your model, API key, and other settings via environment variables @@ -20,31 +19,20 @@ You'll need to be sure to set your model, API key, and other settings via enviro ## With Docker -1. Set `WORKSPACE_BASE` to the directory you want OpenHands to edit: - -```bash -WORKSPACE_BASE=$(pwd)/workspace -``` - -2. Set `LLM_MODEL` to the model you want to use: +To run OpenHands in Headless mode with Docker: -```bash -LLM_MODEL="anthropic/claude-3-5-sonnet-20241022" +1. Set the following environmental variables in your terminal: -``` +* `WORKSPACE_BASE` to the directory you want OpenHands to edit (Ex: `export WORKSPACE_BASE=$(pwd)/workspace`). +* `LLM_MODEL` to the model to use (Ex: `export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"`). +* `LLM_API_KEY` to the API key (Ex: `export LLM_API_KEY="sk_test_12345"`). -3. Set `LLM_API_KEY` to your API key: - -```bash -LLM_API_KEY="sk_test_12345" -``` - -4. Run the following Docker command: +2. Run the following Docker command: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -52,8 +40,17 @@ docker run -it \ -e LOG_ALL_EVENTS=true \ -v $WORKSPACE_BASE:/opt/workspace_base \ -v /var/run/docker.sock:/var/run/docker.sock \ + -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 \ - python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue + docker.all-hands.dev/all-hands-ai/openhands:0.18 \ + python -m openhands.core.main -t "write a bash script that prints hi" ``` + +## Advanced Headless Configurations + +To view all available configuration options for headless mode, run the Python command with the `--help` flag. + +### Additional Logs + +For the headless mode to log all the agent actions, in your terminal run: `export LOG_ALL_EVENTS=true` diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index ded22a995c43..9dd2790d9b4b 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -11,20 +11,25 @@ The easiest way to run OpenHands is in Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.17 + docker.all-hands.dev/all-hands-ai/openhands:0.18 ``` -You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action). +You'll find OpenHands running at http://localhost:3000! + +You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem), +run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), +interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), +or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action). ## Setup diff --git a/docs/modules/usage/runtimes.md b/docs/modules/usage/runtimes.md index 779857c895bb..1aeb82aa00be 100644 --- a/docs/modules/usage/runtimes.md +++ b/docs/modules/usage/runtimes.md @@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible: ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/evaluation/benchmarks/EDA/run_infer.py b/evaluation/benchmarks/EDA/run_infer.py index e8cee3df3e20..b5a021a0b853 100644 --- a/evaluation/benchmarks/EDA/run_infer.py +++ b/evaluation/benchmarks/EDA/run_infer.py @@ -75,6 +75,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/agent_bench/run_infer.py b/evaluation/benchmarks/agent_bench/run_infer.py index a64c66f22cdc..cf1dc6bba97c 100644 --- a/evaluation/benchmarks/agent_bench/run_infer.py +++ b/evaluation/benchmarks/agent_bench/run_infer.py @@ -59,6 +59,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/aider_bench/run_infer.py b/evaluation/benchmarks/aider_bench/run_infer.py index bc850dbc6261..f0e86f30380e 100644 --- a/evaluation/benchmarks/aider_bench/run_infer.py +++ b/evaluation/benchmarks/aider_bench/run_infer.py @@ -67,6 +67,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False # copy 'draft_editor' config if exists config_copy = copy.deepcopy(config) diff --git a/evaluation/benchmarks/biocoder/run_infer.py b/evaluation/benchmarks/biocoder/run_infer.py index c33c75e5a221..ba8eb4d17b20 100644 --- a/evaluation/benchmarks/biocoder/run_infer.py +++ b/evaluation/benchmarks/biocoder/run_infer.py @@ -73,6 +73,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/bird/run_infer.py b/evaluation/benchmarks/bird/run_infer.py index 14946ebacb2f..45ddf582dc64 100644 --- a/evaluation/benchmarks/bird/run_infer.py +++ b/evaluation/benchmarks/bird/run_infer.py @@ -86,6 +86,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/browsing_delegation/run_infer.py b/evaluation/benchmarks/browsing_delegation/run_infer.py index 016b6c3f582e..3313c9ff4c3d 100644 --- a/evaluation/benchmarks/browsing_delegation/run_infer.py +++ b/evaluation/benchmarks/browsing_delegation/run_infer.py @@ -50,6 +50,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/discoverybench/run_infer.py b/evaluation/benchmarks/discoverybench/run_infer.py index 77164e7199ab..a346a2e744ea 100644 --- a/evaluation/benchmarks/discoverybench/run_infer.py +++ b/evaluation/benchmarks/discoverybench/run_infer.py @@ -77,6 +77,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False agent_config = AgentConfig( function_calling=False, codeact_enable_jupyter=True, diff --git a/evaluation/benchmarks/gaia/run_infer.py b/evaluation/benchmarks/gaia/run_infer.py index 8aaa479e92be..7974a092903c 100644 --- a/evaluation/benchmarks/gaia/run_infer.py +++ b/evaluation/benchmarks/gaia/run_infer.py @@ -62,6 +62,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/gorilla/run_infer.py b/evaluation/benchmarks/gorilla/run_infer.py index e453b1f570ba..740a3c3ada8f 100644 --- a/evaluation/benchmarks/gorilla/run_infer.py +++ b/evaluation/benchmarks/gorilla/run_infer.py @@ -55,6 +55,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/gpqa/run_infer.py b/evaluation/benchmarks/gpqa/run_infer.py index 08e66827924e..eb1c808ec8a4 100644 --- a/evaluation/benchmarks/gpqa/run_infer.py +++ b/evaluation/benchmarks/gpqa/run_infer.py @@ -76,6 +76,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/humanevalfix/run_infer.py b/evaluation/benchmarks/humanevalfix/run_infer.py index b2fb6d677a9c..5ab5af818f90 100644 --- a/evaluation/benchmarks/humanevalfix/run_infer.py +++ b/evaluation/benchmarks/humanevalfix/run_infer.py @@ -97,6 +97,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/logic_reasoning/run_infer.py b/evaluation/benchmarks/logic_reasoning/run_infer.py index d84c5f8ca8cb..ee48f5ea76c8 100644 --- a/evaluation/benchmarks/logic_reasoning/run_infer.py +++ b/evaluation/benchmarks/logic_reasoning/run_infer.py @@ -61,6 +61,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/mint/run_infer.py b/evaluation/benchmarks/mint/run_infer.py index a98fa8d91805..61223572ae83 100644 --- a/evaluation/benchmarks/mint/run_infer.py +++ b/evaluation/benchmarks/mint/run_infer.py @@ -119,6 +119,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/ml_bench/run_infer.py b/evaluation/benchmarks/ml_bench/run_infer.py index 1c084fc14916..7f15476423c0 100644 --- a/evaluation/benchmarks/ml_bench/run_infer.py +++ b/evaluation/benchmarks/ml_bench/run_infer.py @@ -92,6 +92,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/swe_bench/scripts/eval/compare_outputs.py b/evaluation/benchmarks/swe_bench/scripts/eval/compare_outputs.py index 2b4b8a40a850..cec92e153d84 100644 --- a/evaluation/benchmarks/swe_bench/scripts/eval/compare_outputs.py +++ b/evaluation/benchmarks/swe_bench/scripts/eval/compare_outputs.py @@ -1,13 +1,20 @@ #!/usr/bin/env python3 import argparse +import os import pandas as pd +from termcolor import colored parser = argparse.ArgumentParser( description='Compare two swe_bench output JSONL files and print the resolved diff' ) parser.add_argument('input_file_1', type=str) parser.add_argument('input_file_2', type=str) +parser.add_argument( + '--show-paths', + action='store_true', + help='Show visualization paths for failed instances', +) args = parser.parse_args() df1 = pd.read_json(args.input_file_1, orient='records', lines=True) @@ -58,10 +65,60 @@ def _get_resolved(report): print(f'# y resolved but x not={df_diff_y_only.shape[0]}') print(df_diff_y_only[['instance_id', 'report_x', 'report_y']]) # get instance_id from df_diff_y_only -print('-' * 100) -print('Instances that x resolved but y not:') -print(df_diff_x_only['instance_id'].tolist()) + +x_only_by_repo = {} +for instance_id in df_diff_x_only['instance_id'].tolist(): + repo = instance_id.split('__')[0] + x_only_by_repo.setdefault(repo, []).append(instance_id) +y_only_by_repo = {} +for instance_id in df_diff_y_only['instance_id'].tolist(): + repo = instance_id.split('__')[0] + y_only_by_repo.setdefault(repo, []).append(instance_id) print('-' * 100) -print('Instances that y resolved but x not:') -print(df_diff_y_only['instance_id'].tolist()) +print( + colored('Repository comparison (x resolved vs y resolved):', 'cyan', attrs=['bold']) +) +all_repos = sorted(set(list(x_only_by_repo.keys()) + list(y_only_by_repo.keys()))) + +# Calculate diffs and sort repos by diff magnitude +repo_diffs = [] +for repo in all_repos: + x_count = len(x_only_by_repo.get(repo, [])) + y_count = len(y_only_by_repo.get(repo, [])) + diff = abs(x_count - y_count) + repo_diffs.append((repo, diff)) + +# Sort by diff (descending) and then by repo name +repo_diffs.sort(key=lambda x: (-x[1], x[0])) +threshold = max( + 3, sum(d[1] for d in repo_diffs) / len(repo_diffs) * 1.5 if repo_diffs else 0 +) + +x_input_file_folder = os.path.join(os.path.dirname(args.input_file_1), 'output.viz') + +for repo, diff in repo_diffs: + x_instances = x_only_by_repo.get(repo, []) + y_instances = y_only_by_repo.get(repo, []) + + # Determine if this repo has a significant diff + is_significant = diff >= threshold + repo_color = 'red' if is_significant else 'yellow' + print(colored(f'Difference: {diff} instances!', repo_color, attrs=['bold'])) + + print(f"\n{colored(repo, repo_color, attrs=['bold'])}:") + print(colored(f'X resolved but Y failed: ({len(x_instances)} instances)', 'green')) + if x_instances: + print(' ' + str(x_instances)) + print(colored(f'Y resolved but X failed: ({len(y_instances)} instances)', 'red')) + if y_instances: + print(' ' + str(y_instances)) + if args.show_paths: + print( + colored(' Visualization path for X failed:', 'cyan', attrs=['bold']) + ) + for instance_id in y_instances: + instance_file = os.path.join( + x_input_file_folder, f'false.{instance_id}.md' + ) + print(f' {instance_file}') diff --git a/evaluation/benchmarks/toolqa/run_infer.py b/evaluation/benchmarks/toolqa/run_infer.py index 6f6f1a0e2048..8586f9a7bb7c 100644 --- a/evaluation/benchmarks/toolqa/run_infer.py +++ b/evaluation/benchmarks/toolqa/run_infer.py @@ -56,6 +56,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/evaluation/benchmarks/webarena/run_infer.py b/evaluation/benchmarks/webarena/run_infer.py index ac51a201a712..c35c79ba2cce 100644 --- a/evaluation/benchmarks/webarena/run_infer.py +++ b/evaluation/benchmarks/webarena/run_infer.py @@ -77,6 +77,8 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.use_microagents = False return config diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 973a16d01049..e91caba167a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "@monaco-editor/react": "^4.7.0-rc.0", "@nextui-org/react": "^2.6.10", diff --git a/frontend/package.json b/frontend/package.json index 1048fea0df75..a4b4b7c38e69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.17.0", + "version": "0.18.0", "private": true, "type": "module", "engines": { diff --git a/frontend/src/types/action-type.tsx b/frontend/src/types/action-type.tsx index b658b3824c29..8571dcc29a06 100644 --- a/frontend/src/types/action-type.tsx +++ b/frontend/src/types/action-type.tsx @@ -36,12 +36,6 @@ enum ActionType { // Reject a request from user or another agent. REJECT = "reject", - // Adds a task to the plan. - ADD_TASK = "add_task", - - // Updates a task in the plan. - MODIFY_TASK = "modify_task", - // Changes the state of the agent, e.g. to paused or running CHANGE_AGENT_STATE = "change_agent_state", } diff --git a/frontend/src/types/core/actions.ts b/frontend/src/types/core/actions.ts index b88393c5a723..eb8aba6ada63 100644 --- a/frontend/src/types/core/actions.ts +++ b/frontend/src/types/core/actions.ts @@ -78,27 +78,6 @@ export interface BrowseInteractiveAction }; } -export interface AddTaskAction extends OpenHandsActionEvent<"add_task"> { - source: "agent"; - timeout: number; - args: { - parent: string; - goal: string; - subtasks: unknown[]; - thought: string; - }; -} - -export interface ModifyTaskAction extends OpenHandsActionEvent<"modify_task"> { - source: "agent"; - timeout: number; - args: { - task_id: string; - state: string; - thought: string; - }; -} - export interface FileReadAction extends OpenHandsActionEvent<"read"> { source: "agent"; args: { @@ -144,6 +123,4 @@ export type OpenHandsAction = | FileReadAction | FileEditAction | FileWriteAction - | AddTaskAction - | ModifyTaskAction | RejectAction; diff --git a/frontend/src/types/core/base.ts b/frontend/src/types/core/base.ts index ce3fba3f0884..9b03f4f4e428 100644 --- a/frontend/src/types/core/base.ts +++ b/frontend/src/types/core/base.ts @@ -10,8 +10,6 @@ export type OpenHandsEventType = | "browse" | "browse_interactive" | "reject" - | "add_task" - | "modify_task" | "finish" | "error"; diff --git a/microagents/README.md b/microagents/README.md new file mode 100644 index 000000000000..044c15539d3e --- /dev/null +++ b/microagents/README.md @@ -0,0 +1,163 @@ +# OpenHands MicroAgents + +MicroAgents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They help developers by providing expert guidance, automating common tasks, and ensuring consistent practices across projects. Each microagent is designed to excel in a specific area, from Git operations to code review processes. + +## Sources of Microagents + +OpenHands loads microagents from two sources: + +### 1. Shareable Microagents (Public) +This directory (`OpenHands/microagents/`) contains shareable microagents that are: +- Available to all OpenHands users +- Maintained in the OpenHands repository +- Perfect for reusable knowledge and common workflows + +Directory structure: +``` +OpenHands/microagents/ +├── knowledge/ # Keyword-triggered expertise +│ ├── git.md # Git operations +│ ├── testing.md # Testing practices +│ └── docker.md # Docker guidelines +└── tasks/ # Interactive workflows + ├── pr_review.md # PR review process + ├── bug_fix.md # Bug fixing workflow + └── feature.md # Feature implementation +``` + +### 2. Repository Instructions (Private) +Each repository can have its own instructions in `.openhands/microagents/repo.md`. These instructions are: +- Private to that repository +- Automatically loaded when working with that repository +- Perfect for repository-specific guidelines and team practices + +Example repository structure: +``` +your-repository/ +└── .openhands/ + └── microagents/ + └── repo.md # Repository-specific instructions + └── knowledges/ # Private micro-agents that are only available inside this repo + └── tasks/ # Private micro-agents that are only available inside this repo +``` + + +## Loading Order + +When OpenHands works with a repository, it: +1. Loads repository-specific instructions from `.openhands/microagents/repo.md` if present +2. Loads relevant knowledge agents based on keywords in conversations +3. Enable task agent if user select one of them + +## Types of MicroAgents + +All microagents use markdown files with YAML frontmatter. + + +### 1. Knowledge Agents + +Knowledge agents provide specialized expertise that's triggered by keywords in conversations. They help with: +- Language best practices +- Framework guidelines +- Common patterns +- Tool usage + +Key characteristics: +- **Trigger-based**: Activated by specific keywords in conversations +- **Context-aware**: Provide relevant advice based on file types and content +- **Reusable**: Knowledge can be applied across multiple projects +- **Versioned**: Support multiple versions of tools/frameworks + +You can see an example of a knowledge-based agent in [OpenHands's github microagent](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/github.md). + +### 2. Repository Agents + +Repository agents provide repository-specific knowledge and guidelines. They are: +- Loaded from `.openhands/microagents/repo.md` +- Specific to individual repositories +- Automatically activated for their repository +- Perfect for team practices and project conventions + +Key features: +- **Project-specific**: Contains guidelines unique to the repository +- **Team-focused**: Enforces team conventions and practices +- **Always active**: Automatically loaded for the repository +- **Locally maintained**: Updated with the project + +You can see an example of a repo agent in [the agent for the OpenHands repo itself](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md). + +### 3. Task Agents + +Task agents provide interactive workflows that guide users through common development tasks. They: +- Accept user inputs +- Follow predefined steps +- Adapt to context +- Provide consistent results + +Key capabilities: +- **Interactive**: Guide users through complex processes +- **Validating**: Check inputs and conditions +- **Flexible**: Adapt to different scenarios +- **Reproducible**: Ensure consistent outcomes + +Example workflow: +You can see an example of a task-based agent in [OpenHands's pull request updating microagent](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks/update_pr_description.md). + +## Contributing + +### When to Contribute + +1. **Knowledge Agents** - When you have: + - Language/framework best practices + - Tool usage patterns + - Common problem solutions + - General development guidelines + +2. **Task Agents** - When you have: + - Repeatable workflows + - Multi-step processes + - Common development tasks + - Standard procedures + +3. **Repository Agents** - When you need: + - Project-specific guidelines + - Team conventions and practices + - Custom workflow documentation + - Repository-specific setup instructions + +### Best Practices + +1. **For Knowledge Agents**: + - Choose distinctive triggers + - Focus on one area of expertise + - Include practical examples + - Use file patterns when relevant + - Keep knowledge general and reusable + +2. **For Task Agents**: + - Break workflows into clear steps + - Validate user inputs + - Provide helpful defaults + - Include usage examples + - Make steps adaptable + +3. **For Repository Agents**: + - Document clear setup instructions + - Include repository structure details + - Specify testing and build procedures + - List environment requirements + - Maintain up-to-date team practices + +### Submission Process + +1. Create your agent file in the appropriate directory: + - `knowledge/` for expertise (public, shareable) + - `tasks/` for workflows (public, shareable) + - Note: Repository agents should remain in their respective repositories' `.openhands/microagents/` directory +2. Test thoroughly +3. Submit a pull request to OpenHands + + +## License + +All microagents are subject to the same license as OpenHands. See the root LICENSE file for details. diff --git a/openhands/agenthub/codeact_agent/micro/flarglebargle.md b/microagents/knowledge/flarglebargle.md similarity index 86% rename from openhands/agenthub/codeact_agent/micro/flarglebargle.md rename to microagents/knowledge/flarglebargle.md index 96189965f546..b57229bbfe4c 100644 --- a/openhands/agenthub/codeact_agent/micro/flarglebargle.md +++ b/microagents/knowledge/flarglebargle.md @@ -1,5 +1,7 @@ --- name: flarglebargle +type: knowledge +version: 1.0.0 agent: CodeActAgent triggers: - flarglebargle diff --git a/openhands/agenthub/codeact_agent/micro/github.md b/microagents/knowledge/github.md similarity index 97% rename from openhands/agenthub/codeact_agent/micro/github.md rename to microagents/knowledge/github.md index 00afa6c1bd4b..f3f3f11cd7f7 100644 --- a/openhands/agenthub/codeact_agent/micro/github.md +++ b/microagents/knowledge/github.md @@ -1,5 +1,7 @@ --- name: github +type: knowledge +version: 1.0.0 agent: CodeActAgent triggers: - github @@ -26,4 +28,3 @@ git checkout -b create-widget && git add . && git commit -m "Create widget" && g curl -X POST "https://api.github.com/repos/$ORG_NAME/$REPO_NAME/pulls" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}' -``` diff --git a/openhands/agenthub/codeact_agent/micro/npm.md b/microagents/knowledge/npm.md similarity index 90% rename from openhands/agenthub/codeact_agent/micro/npm.md rename to microagents/knowledge/npm.md index a84e52792bc3..e40a4a9ad301 100644 --- a/openhands/agenthub/codeact_agent/micro/npm.md +++ b/microagents/knowledge/npm.md @@ -1,5 +1,7 @@ --- name: npm +type: knowledge +version: 1.0.0 agent: CodeActAgent triggers: - npm diff --git a/microagents/tasks/address_pr_comments.md b/microagents/tasks/address_pr_comments.md new file mode 100644 index 000000000000..db7d406e3919 --- /dev/null +++ b/microagents/tasks/address_pr_comments.md @@ -0,0 +1,20 @@ +--- +name: address_pr_comments +type: task +version: 1.0.0 +author: openhands +agent: CodeActAgent +inputs: + - name: PR_URL + description: "URL of the pull request" + required: true + - name: BRANCH_NAME + description: "Branch name corresponds to the pull request" + required: true +--- + +First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose. + +This branch corresponds to this PR {{ PR_URL }} + +Next, you should use the GitHub API to read the reviews and comments on this PR and address them. diff --git a/microagents/tasks/get_test_to_pass.md b/microagents/tasks/get_test_to_pass.md new file mode 100644 index 000000000000..9f2097c87584 --- /dev/null +++ b/microagents/tasks/get_test_to_pass.md @@ -0,0 +1,28 @@ +--- +name: get_test_to_pass +type: task +version: 1.0.0 +author: openhands +agent: CodeActAgent +inputs: + - name: BRANCH_NAME + description: "Branch for the agent to work on" + required: true + - name: TEST_COMMAND_TO_RUN + description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`" + required: true + - name: FUNCTION_TO_FIX + description: "The name of function to fix" + required: false + - name: FILE_FOR_FUNCTION + description: "The path of the file that contains the function" + required: false +--- + +Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}. + +{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %} +Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}. +{%- endif %} + +PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect. diff --git a/microagents/tasks/update_pr_description.md b/microagents/tasks/update_pr_description.md new file mode 100644 index 000000000000..509256ef6c21 --- /dev/null +++ b/microagents/tasks/update_pr_description.md @@ -0,0 +1,22 @@ +--- +name: update_pr_description +type: task +version: 1.0.0 +author: openhands +agent: CodeActAgent +inputs: + - name: PR_URL + description: "URL of the pull request" + type: string + required: true + validation: + pattern: "^https://github.com/.+/.+/pull/[0-9]+$" + - name: BRANCH_NAME + description: "Branch name corresponds to the pull request" + type: string + required: true +--- + +Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}". + +Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary. diff --git a/microagents/tasks/update_test_for_new_implementation.md b/microagents/tasks/update_test_for_new_implementation.md new file mode 100644 index 000000000000..c694d8009e49 --- /dev/null +++ b/microagents/tasks/update_test_for_new_implementation.md @@ -0,0 +1,22 @@ +--- +name: update_test_for_new_implementation +type: task +version: 1.0.0 +author: openhands +agent: CodeActAgent +inputs: + - name: BRANCH_NAME + description: "Branch for the agent to work on" + required: true + - name: TEST_COMMAND_TO_RUN + description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`" + required: true +--- + +Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}. + +{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %} +Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}. +{%- endif %} + +PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect. diff --git a/openhands/agenthub/__init__.py b/openhands/agenthub/__init__.py index 85ae41d425d0..892c0d682d2e 100644 --- a/openhands/agenthub/__init__.py +++ b/openhands/agenthub/__init__.py @@ -12,12 +12,10 @@ codeact_agent, delegator_agent, dummy_agent, - planner_agent, ) __all__ = [ 'codeact_agent', - 'planner_agent', 'delegator_agent', 'dummy_agent', 'browsing_agent', diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index 2a99c24104d6..eb3fc2208ba1 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -5,6 +5,7 @@ from litellm import ModelResponse +import openhands import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling from openhands.agenthub.codeact_agent.action_parser import CodeActResponseParser from openhands.controller.agent import Agent @@ -125,7 +126,10 @@ def __init__( f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2, ensure_ascii=False).replace("\\n", "\n")}' ) self.prompt_manager = PromptManager( - microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') + microagent_dir=os.path.join( + os.path.dirname(os.path.dirname(openhands.__file__)), + 'microagents', + ) if self.config.use_microagents else None, prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'), diff --git a/openhands/agenthub/dummy_agent/agent.py b/openhands/agenthub/dummy_agent/agent.py index 113cc3544f0d..63f5c6a8c41f 100644 --- a/openhands/agenthub/dummy_agent/agent.py +++ b/openhands/agenthub/dummy_agent/agent.py @@ -1,4 +1,4 @@ -from typing import TypedDict, Union +from typing import TypedDict from openhands.controller.agent import Agent from openhands.controller.state.state import State @@ -6,7 +6,6 @@ from openhands.core.schema import AgentState from openhands.events.action import ( Action, - AddTaskAction, AgentFinishAction, AgentRejectAction, BrowseInteractiveAction, @@ -15,10 +14,10 @@ FileReadAction, FileWriteAction, MessageAction, - ModifyTaskAction, ) from openhands.events.observation import ( AgentStateChangedObservation, + BrowserOutputObservation, CmdOutputObservation, FileReadObservation, FileWriteObservation, @@ -49,20 +48,6 @@ class DummyAgent(Agent): def __init__(self, llm: LLM, config: AgentConfig): super().__init__(llm, config) self.steps: list[ActionObs] = [ - { - 'action': AddTaskAction( - parent='None', goal='check the current directory' - ), - 'observations': [], - }, - { - 'action': AddTaskAction(parent='0', goal='run ls'), - 'observations': [], - }, - { - 'action': ModifyTaskAction(task_id='0', state='in_progress'), - 'observations': [], - }, { 'action': MessageAction('Time to get started!'), 'observations': [], @@ -105,7 +90,12 @@ def __init__(self, llm: LLM, config: AgentConfig): { 'action': BrowseURLAction(url='https://google.com'), 'observations': [ - # BrowserOutputObservation('Simulated Google page',url='https://google.com',screenshot=''), + BrowserOutputObservation( + 'Simulated Google page', + url='https://google.com', + screenshot='', + trigger_by_action='', + ), ], }, { @@ -113,7 +103,12 @@ def __init__(self, llm: LLM, config: AgentConfig): browser_actions='goto("https://google.com")' ), 'observations': [ - # BrowserOutputObservation('Simulated Google page after interaction',url='https://google.com',screenshot=''), + BrowserOutputObservation( + 'Simulated Google page after interaction', + url='https://google.com', + screenshot='', + trigger_by_action='', + ), ], }, { @@ -135,30 +130,6 @@ def step(self, state: State) -> Action: current_step = self.steps[state.iteration] action = current_step['action'] - # If the action is AddTaskAction or ModifyTaskAction, update the parent ID or task_id - if isinstance(action, AddTaskAction): - if action.parent == 'None': - action.parent = '' # Root task has no parent - elif action.parent == '0': - action.parent = state.root_task.id - elif action.parent.startswith('0.'): - action.parent = f'{state.root_task.id}{action.parent[1:]}' - elif isinstance(action, ModifyTaskAction): - if action.task_id == '0': - action.task_id = state.root_task.id - elif action.task_id.startswith('0.'): - action.task_id = f'{state.root_task.id}{action.task_id[1:]}' - # Ensure the task_id doesn't start with a dot - if action.task_id.startswith('.'): - action.task_id = action.task_id[1:] - elif isinstance(action, (BrowseURLAction, BrowseInteractiveAction)): - try: - return self.simulate_browser_action(action) - except ( - Exception - ): # This could be a specific exception for browser unavailability - return self.handle_browser_unavailable(action) - if state.iteration > 0: prev_step = self.steps[state.iteration - 1] @@ -190,22 +161,3 @@ def step(self, state: State) -> Action: ) return action - - def simulate_browser_action( - self, action: Union[BrowseURLAction, BrowseInteractiveAction] - ) -> Action: - # Instead of simulating, we'll reject the browser action - return self.handle_browser_unavailable(action) - - def handle_browser_unavailable( - self, action: Union[BrowseURLAction, BrowseInteractiveAction] - ) -> Action: - # Create a message action to inform that browsing is not available - message = 'Browser actions are not available in the DummyAgent environment.' - if isinstance(action, BrowseURLAction): - message += f' Unable to browse URL: {action.url}' - elif isinstance(action, BrowseInteractiveAction): - message += ( - f' Unable to perform interactive browsing: {action.browser_actions}' - ) - return MessageAction(content=message) diff --git a/openhands/agenthub/planner_agent/__init__.py b/openhands/agenthub/planner_agent/__init__.py deleted file mode 100644 index e8c030e84c09..000000000000 --- a/openhands/agenthub/planner_agent/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from openhands.agenthub.planner_agent.agent import PlannerAgent -from openhands.controller.agent import Agent - -Agent.register('PlannerAgent', PlannerAgent) diff --git a/openhands/agenthub/planner_agent/agent.py b/openhands/agenthub/planner_agent/agent.py deleted file mode 100644 index f5aef523d9b9..000000000000 --- a/openhands/agenthub/planner_agent/agent.py +++ /dev/null @@ -1,53 +0,0 @@ -from openhands.agenthub.planner_agent.prompt import get_prompt_and_images -from openhands.agenthub.planner_agent.response_parser import PlannerResponseParser -from openhands.controller.agent import Agent -from openhands.controller.state.state import State -from openhands.core.config import AgentConfig -from openhands.core.message import ImageContent, Message, TextContent -from openhands.events.action import Action, AgentFinishAction -from openhands.llm.llm import LLM - - -class PlannerAgent(Agent): - VERSION = '1.0' - """ - The planner agent utilizes a special prompting strategy to create long term plans for solving problems. - The agent is given its previous action-observation pairs, current task, and hint based on last action taken at every step. - """ - response_parser = PlannerResponseParser() - - def __init__(self, llm: LLM, config: AgentConfig): - """Initialize the Planner Agent with an LLM - - Parameters: - - llm (LLM): The llm to be used by this agent - """ - super().__init__(llm, config) - - def step(self, state: State) -> Action: - """Checks to see if current step is completed, returns AgentFinishAction if True. - Otherwise, creates a plan prompt and sends to model for inference, returning the result as the next action. - - Parameters: - - state (State): The current state given the previous actions and observations - - Returns: - - AgentFinishAction: If the last state was 'completed', 'verified', or 'abandoned' - - Action: The next action to take based on llm response - """ - if state.root_task.state in [ - 'completed', - 'verified', - 'abandoned', - ]: - return AgentFinishAction() - - prompt, image_urls = get_prompt_and_images( - state, self.llm.config.max_message_chars - ) - content = [TextContent(text=prompt)] - if self.llm.vision_is_active() and image_urls: - content.append(ImageContent(image_urls=image_urls)) - message = Message(role='user', content=content) - resp = self.llm.completion(messages=self.llm.format_messages_for_llm(message)) - return self.response_parser.parse(resp) diff --git a/openhands/agenthub/planner_agent/response_parser.py b/openhands/agenthub/planner_agent/response_parser.py deleted file mode 100644 index 12068cd5b769..000000000000 --- a/openhands/agenthub/planner_agent/response_parser.py +++ /dev/null @@ -1,37 +0,0 @@ -from openhands.controller.action_parser import ResponseParser -from openhands.core.utils import json -from openhands.events.action import ( - Action, -) -from openhands.events.serialization.action import action_from_dict - - -class PlannerResponseParser(ResponseParser): - def __init__(self): - super().__init__() - - def parse(self, response: str) -> Action: - action_str = self.parse_response(response) - return self.parse_action(action_str) - - def parse_response(self, response) -> str: - # get the next action from the response - return response['choices'][0]['message']['content'] - - def parse_action(self, action_str: str) -> Action: - """Parses a string to find an action within it - - Parameters: - - response (str): The string to be parsed - - Returns: - - Action: The action that was found in the response string - """ - # attempt to load the JSON dict from the response - action_dict = json.loads(action_str) - - if 'content' in action_dict: - # The LLM gets confused here. Might as well be robust - action_dict['contents'] = action_dict.pop('content') - - return action_from_dict(action_dict) diff --git a/openhands/controller/agent.py b/openhands/controller/agent.py index fd2657ebc2a8..43a55d935249 100644 --- a/openhands/controller/agent.py +++ b/openhands/controller/agent.py @@ -11,7 +11,9 @@ ) from openhands.llm.llm import LLM from openhands.runtime.plugins import PluginRequirement -from openhands.utils.prompt import PromptManager + +if TYPE_CHECKING: + from openhands.utils.prompt import PromptManager class Agent(ABC): @@ -34,7 +36,7 @@ def __init__( self.llm = llm self.config = config self._complete = False - self.prompt_manager: PromptManager | None = None + self.prompt_manager: 'PromptManager' | None = None @property def complete(self) -> bool: diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index d7dfee1c8e46..b3faa068b8ba 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -25,7 +25,6 @@ from openhands.events.action import ( Action, ActionConfirmationStatus, - AddTaskAction, AgentDelegateAction, AgentFinishAction, AgentRejectAction, @@ -34,7 +33,6 @@ CmdRunAction, IPythonRunCellAction, MessageAction, - ModifyTaskAction, NullAction, RegenerateAction, ) @@ -280,12 +278,7 @@ async def _handle_action(self, action: Action) -> None: await self._handle_message_action(action) elif isinstance(action, AgentDelegateAction): await self.start_delegate(action) - elif isinstance(action, AddTaskAction): - self.state.root_task.add_subtask( - action.parent, action.goal, action.subtasks - ) - elif isinstance(action, ModifyTaskAction): - self.state.root_task.set_subtask_state(action.task_id, action.state) + elif isinstance(action, AgentFinishAction): self.state.outputs = action.outputs self.state.metrics.merge(self.state.local_metrics) diff --git a/openhands/core/cli.py b/openhands/core/cli.py index f1fad482ee83..dda95a5034d8 100644 --- a/openhands/core/cli.py +++ b/openhands/core/cli.py @@ -1,15 +1,12 @@ import asyncio import logging import sys -from typing import Type from uuid import uuid4 from termcolor import colored import openhands.agenthub # noqa F401 (we import this to get the agents registered) from openhands import __version__ -from openhands.controller import AgentController -from openhands.controller.agent import Agent from openhands.core.config import ( AppConfig, get_parser, @@ -18,7 +15,8 @@ from openhands.core.logger import openhands_logger as logger from openhands.core.loop import run_agent_until_done from openhands.core.schema import AgentState -from openhands.events import EventSource, EventStream, EventStreamSubscriber +from openhands.core.setup import create_agent, create_controller, create_runtime +from openhands.events import EventSource, EventStreamSubscriber from openhands.events.action import ( Action, ActionConfirmationStatus, @@ -36,11 +34,6 @@ NullObservation, ) from openhands.events.observation.commands import IPythonRunCellObservation -from openhands.llm.llm import LLM -from openhands.runtime import get_runtime_cls -from openhands.runtime.base import Runtime -from openhands.security import SecurityAnalyzer, options -from openhands.storage import get_file_store def display_message(message: str): @@ -120,39 +113,12 @@ async def main(loop): config = load_app_config(config_file=args.config_file) sid = str(uuid4()) - agent_cls: Type[Agent] = Agent.get_cls(config.default_agent) - agent_config = config.get_agent_config(config.default_agent) - llm_config = config.get_llm_config_from_agent(config.default_agent) - agent = agent_cls( - llm=LLM(config=llm_config), - config=agent_config, - ) - - file_store = get_file_store(config.file_store, config.file_store_path) - event_stream = EventStream(sid, file_store) - - runtime_cls = get_runtime_cls(config.runtime) - runtime: Runtime = runtime_cls( # noqa: F841 - config=config, - event_stream=event_stream, - sid=sid, - plugins=agent_cls.sandbox_plugins, - headless_mode=True, - ) + runtime = create_runtime(config, sid=sid, headless_mode=True) + await runtime.connect() + agent = create_agent(runtime, config) + controller, _ = create_controller(agent, runtime, config) - if config.security.security_analyzer: - options.SecurityAnalyzers.get( - config.security.security_analyzer, SecurityAnalyzer - )(event_stream) - - controller = AgentController( - agent=agent, - max_iterations=config.max_iterations, - max_budget_per_task=config.max_budget_per_task, - agent_to_llm_config=config.get_agent_to_llm_config_map(), - event_stream=event_stream, - confirmation_mode=config.security.confirmation_mode, - ) + event_stream = runtime.event_stream async def prompt_for_next_task(): # Run input() in a thread pool to avoid blocking the event loop diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py index 8e361a9616a3..6a0ebf4c3945 100644 --- a/openhands/core/config/llm_config.py +++ b/openhands/core/config/llm_config.py @@ -48,6 +48,7 @@ class LLMConfig: draft_editor: A more efficient LLM to use for file editing. Introduced in [PR 3985](https://github.com/All-Hands-AI/OpenHands/pull/3985). custom_tokenizer: A custom tokenizer to use for token counting. use_group: The group to use for the LLM. + native_tool_calling: Whether to use native tool calling if supported by the model. Can be True, False, or not set. """ model: str = 'claude-3-5-sonnet-20241022' @@ -89,6 +90,7 @@ class LLMConfig: draft_editor: Optional['LLMConfig'] = None custom_tokenizer: str | None = None use_group: str | None = None + native_tool_calling: bool | None = None def defaults_to_dict(self) -> dict: """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index 96e733de1ee8..fe90948dcba5 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -94,6 +94,10 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): Args: cfg: The AppConfig object to update attributes of. toml_file: The path to the toml file. Defaults to 'config.toml'. + + See Also: + - `config.template.toml` for the full list of config options. + - `SandboxConfig` for the sandbox-specific config options. """ # try to read the config.toml file into the config object try: @@ -157,11 +161,11 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): ) except (TypeError, KeyError) as e: logger.openhands_logger.warning( - f'Cannot parse config from toml, toml values have not been applied.\n Error: {e}', + f'Cannot parse [{key}] config from toml, values have not been applied.\nError: {e}', exc_info=False, ) else: - logger.openhands_logger.warning(f'Unknown key in {toml_file}: "{key}') + logger.openhands_logger.warning(f'Unknown section [{key}] in {toml_file}') try: # set sandbox config from the toml file @@ -175,7 +179,9 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): # read the key in sandbox and remove it from core setattr(sandbox_config, new_key, core_config.pop(key)) else: - logger.openhands_logger.warning(f'Unknown sandbox config: {key}') + logger.openhands_logger.warning( + f'Unknown config key "{key}" in [sandbox] section' + ) # the new style values override the old style values if 'sandbox' in toml_config: @@ -187,10 +193,12 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): if hasattr(cfg, key): setattr(cfg, key, value) else: - logger.openhands_logger.warning(f'Unknown core config key: {key}') + logger.openhands_logger.warning( + f'Unknown config key "{key}" in [core] section' + ) except (TypeError, KeyError) as e: logger.openhands_logger.warning( - f'Cannot parse config from toml, toml values have not been applied.\nError: {e}', + f'Cannot parse [sandbox] config from toml, values have not been applied.\nError: {e}', exc_info=False, ) diff --git a/openhands/core/exceptions.py b/openhands/core/exceptions.py index cd231336860b..4920c134dc6f 100644 --- a/openhands/core/exceptions.py +++ b/openhands/core/exceptions.py @@ -110,11 +110,6 @@ def __init__(self, message='User cancelled the request'): super().__init__(message) -class MicroAgentValidationError(Exception): - def __init__(self, message='Micro agent validation failed'): - super().__init__(message) - - class OperationCancelled(Exception): """Exception raised when an operation is cancelled (e.g. by a keyboard interrupt).""" @@ -223,3 +218,21 @@ def __init__( message='Browser environment is not available, please check if has been initialized', ): super().__init__(message) + + +# ============================================ +# Microagent Exceptions +# ============================================ + + +class MicroAgentError(Exception): + """Base exception for all microagent errors.""" + + pass + + +class MicroAgentValidationError(MicroAgentError): + """Raised when there's a validation error in microagent metadata.""" + + def __init__(self, message='Micro agent validation failed'): + super().__init__(message) diff --git a/openhands/core/main.py b/openhands/core/main.py index ebd045f8c93c..deafabab7536 100644 --- a/openhands/core/main.py +++ b/openhands/core/main.py @@ -1,13 +1,10 @@ import asyncio -import hashlib import json import os import sys -import uuid -from typing import Callable, Protocol, Type +from typing import Callable, Protocol import openhands.agenthub # noqa F401 (we import this to get the agents registered) -from openhands.controller import AgentController from openhands.controller.agent import Agent from openhands.controller.state.state import State from openhands.core.config import ( @@ -19,16 +16,19 @@ from openhands.core.logger import openhands_logger as logger from openhands.core.loop import run_agent_until_done from openhands.core.schema import AgentState -from openhands.events import EventSource, EventStream, EventStreamSubscriber +from openhands.core.setup import ( + create_agent, + create_controller, + create_runtime, + generate_sid, +) +from openhands.events import EventSource, EventStreamSubscriber from openhands.events.action import MessageAction from openhands.events.action.action import Action from openhands.events.event import Event from openhands.events.observation import AgentStateChangedObservation from openhands.events.serialization.event import event_to_trajectory -from openhands.llm.llm import LLM -from openhands.runtime import get_runtime_cls from openhands.runtime.base import Runtime -from openhands.storage import get_file_store class FakeUserResponseFunc(Protocol): @@ -51,45 +51,6 @@ def read_task_from_stdin() -> str: return sys.stdin.read() -def create_runtime( - config: AppConfig, - sid: str | None = None, - headless_mode: bool = True, -) -> Runtime: - """Create a runtime for the agent to run on. - - config: The app config. - sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing. - Set it to incompatible value will cause unexpected behavior on RemoteRuntime. - headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts, - where we don't want to have the VSCode UI open, so it defaults to True. - """ - # if sid is provided on the command line, use it as the name of the event stream - # otherwise generate it on the basis of the configured jwt_secret - # we can do this better, this is just so that the sid is retrieved when we want to restore the session - session_id = sid or generate_sid(config) - - # set up the event stream - file_store = get_file_store(config.file_store, config.file_store_path) - event_stream = EventStream(session_id, file_store) - - # agent class - agent_cls = openhands.agenthub.Agent.get_cls(config.default_agent) - - # runtime and tools - runtime_cls = get_runtime_cls(config.runtime) - logger.debug(f'Initializing runtime: {runtime_cls.__name__}') - runtime: Runtime = runtime_cls( - config=config, - event_stream=event_stream, - sid=session_id, - plugins=agent_cls.sandbox_plugins, - headless_mode=headless_mode, - ) - - return runtime - - async def run_controller( config: AppConfig, initial_user_action: Action, @@ -115,17 +76,6 @@ async def run_controller( (could be None) and returns a fake user response. headless_mode: Whether the agent is run in headless mode. """ - # Create the agent - if agent is None: - agent_cls: Type[Agent] = Agent.get_cls(config.default_agent) - agent_config = config.get_agent_config(config.default_agent) - llm_config = config.get_llm_config_from_agent(config.default_agent) - agent = agent_cls( - llm=LLM(config=llm_config), - config=agent_config, - ) - - # make sure the session id is set sid = sid or generate_sid(config) if runtime is None: @@ -146,17 +96,10 @@ async def run_controller( ) except Exception as e: logger.debug(f'Cannot restore agent state: {e}') + if agent is None: + agent = create_agent(runtime, config) - # init controller with this initial state - controller = AgentController( - agent=agent, - max_iterations=config.max_iterations, - max_budget_per_task=config.max_budget_per_task, - agent_to_llm_config=config.get_agent_to_llm_config_map(), - event_stream=event_stream, - initial_state=initial_state, - headless_mode=headless_mode, - ) + controller, initial_state = create_controller(agent, runtime, config) assert isinstance( initial_user_action, Action @@ -235,15 +178,6 @@ def on_event(event: Event): return state -def generate_sid(config: AppConfig, session_name: str | None = None) -> str: - """Generate a session id based on the session name and the jwt secret.""" - session_name = session_name or str(uuid.uuid4()) - jwt_secret = config.jwt_secret - - hash_str = hashlib.sha256(f'{session_name}{jwt_secret}'.encode('utf-8')).hexdigest() - return f'{session_name}-{hash_str[:16]}' - - def auto_continue_response( state: State, encapsulate_solution: bool = False, diff --git a/openhands/core/schema/action.py b/openhands/core/schema/action.py index 1485f8d7f4a9..eeb797931e94 100644 --- a/openhands/core/schema/action.py +++ b/openhands/core/schema/action.py @@ -66,10 +66,6 @@ class ActionTypeSchema(BaseModel): SUMMARIZE: str = Field(default='summarize') - ADD_TASK: str = Field(default='add_task') - - MODIFY_TASK: str = Field(default='modify_task') - PAUSE: str = Field(default='pause') """Pauses the task. """ diff --git a/openhands/core/setup.py b/openhands/core/setup.py new file mode 100644 index 000000000000..28888478017a --- /dev/null +++ b/openhands/core/setup.py @@ -0,0 +1,114 @@ +import hashlib +import uuid +from typing import Tuple, Type + +import openhands.agenthub # noqa F401 (we import this to get the agents registered) +from openhands.controller import AgentController +from openhands.controller.agent import Agent +from openhands.controller.state.state import State +from openhands.core.config import ( + AppConfig, +) +from openhands.core.logger import openhands_logger as logger +from openhands.events import EventStream +from openhands.llm.llm import LLM +from openhands.runtime import get_runtime_cls +from openhands.runtime.base import Runtime +from openhands.security import SecurityAnalyzer, options +from openhands.storage import get_file_store + + +def create_runtime( + config: AppConfig, + sid: str | None = None, + headless_mode: bool = True, +) -> Runtime: + """Create a runtime for the agent to run on. + + config: The app config. + sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing. + Set it to incompatible value will cause unexpected behavior on RemoteRuntime. + headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts, + where we don't want to have the VSCode UI open, so it defaults to True. + """ + # if sid is provided on the command line, use it as the name of the event stream + # otherwise generate it on the basis of the configured jwt_secret + # we can do this better, this is just so that the sid is retrieved when we want to restore the session + session_id = sid or generate_sid(config) + + # set up the event stream + file_store = get_file_store(config.file_store, config.file_store_path) + event_stream = EventStream(session_id, file_store) + + # agent class + agent_cls = openhands.agenthub.Agent.get_cls(config.default_agent) + + # runtime and tools + runtime_cls = get_runtime_cls(config.runtime) + logger.debug(f'Initializing runtime: {runtime_cls.__name__}') + runtime: Runtime = runtime_cls( + config=config, + event_stream=event_stream, + sid=session_id, + plugins=agent_cls.sandbox_plugins, + headless_mode=headless_mode, + ) + + return runtime + + +def create_agent(runtime: Runtime, config: AppConfig) -> Agent: + agent_cls: Type[Agent] = Agent.get_cls(config.default_agent) + agent_config = config.get_agent_config(config.default_agent) + llm_config = config.get_llm_config_from_agent(config.default_agent) + agent = agent_cls( + llm=LLM(config=llm_config), + config=agent_config, + ) + if agent.prompt_manager: + microagents = runtime.get_microagents_from_selected_repo(None) + agent.prompt_manager.load_microagents(microagents) + + if config.security.security_analyzer: + options.SecurityAnalyzers.get( + config.security.security_analyzer, SecurityAnalyzer + )(runtime.event_stream) + + return agent + + +def create_controller( + agent: Agent, runtime: Runtime, config: AppConfig, headless_mode: bool = True +) -> Tuple[AgentController, State | None]: + event_stream = runtime.event_stream + initial_state = None + try: + logger.debug( + f'Trying to restore agent state from session {event_stream.sid} if available' + ) + initial_state = State.restore_from_session( + event_stream.sid, event_stream.file_store + ) + except Exception as e: + logger.debug(f'Cannot restore agent state: {e}') + + controller = AgentController( + agent=agent, + max_iterations=config.max_iterations, + max_budget_per_task=config.max_budget_per_task, + agent_to_llm_config=config.get_agent_to_llm_config_map(), + event_stream=event_stream, + initial_state=initial_state, + headless_mode=headless_mode, + confirmation_mode=config.security.confirmation_mode, + ) + return (controller, initial_state) + + +def generate_sid(config: AppConfig, session_name: str | None = None) -> str: + """Generate a session id based on the session name and the jwt secret.""" + session_name = session_name or str(uuid.uuid4()) + jwt_secret = config.jwt_secret + + hash_str = hashlib.sha256(f'{session_name}{jwt_secret}'.encode('utf-8')).hexdigest() + return f'{session_name}-{hash_str[:16]}' diff --git a/openhands/events/action/__init__.py b/openhands/events/action/__init__.py index b67891e7adbc..186b848fdc27 100644 --- a/openhands/events/action/__init__.py +++ b/openhands/events/action/__init__.py @@ -30,8 +30,6 @@ 'AgentRejectAction', 'AgentDelegateAction', 'AgentSummarizeAction', - 'AddTaskAction', - 'ModifyTaskAction', 'ChangeAgentStateAction', 'IPythonRunCellAction', 'MessageAction', diff --git a/openhands/events/serialization/action.py b/openhands/events/serialization/action.py index 1279fba6e958..cea8e6a58bd3 100644 --- a/openhands/events/serialization/action.py +++ b/openhands/events/serialization/action.py @@ -33,8 +33,6 @@ AgentFinishAction, AgentRejectAction, AgentDelegateAction, - AddTaskAction, - ModifyTaskAction, ChangeAgentStateAction, MessageAction, RegenerateAction, diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index f04ff7782bb2..e961cbef2d31 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -565,13 +565,31 @@ def is_caching_prompt_active(self) -> bool: ) def is_function_calling_active(self) -> bool: - # Check if model name is in supported list before checking model_info + # Check if model name is in our supported list model_name_supported = ( self.config.model in FUNCTION_CALLING_SUPPORTED_MODELS or self.config.model.split('/')[-1] in FUNCTION_CALLING_SUPPORTED_MODELS or any(m in self.config.model for m in FUNCTION_CALLING_SUPPORTED_MODELS) ) - return model_name_supported + + # Handle native_tool_calling user-defined configuration + if self.config.native_tool_calling is None: + logger.debug( + f'Using default tool calling behavior based on model evaluation: {model_name_supported}' + ) + return model_name_supported + elif self.config.native_tool_calling is False: + logger.debug('Function calling explicitly disabled via configuration') + return False + else: + # try to enable native tool calling if supported by the model + supports_fn_call = litellm.supports_function_calling( + model=self.config.model + ) + logger.debug( + f'Function calling explicitly enabled, litellm support: {supports_fn_call}' + ) + return supports_fn_call def _post_completion(self, response: ModelResponse) -> float: """Post-process the completion response. diff --git a/openhands/microagent/__init__.py b/openhands/microagent/__init__.py new file mode 100644 index 000000000000..c38a5c77ab3a --- /dev/null +++ b/openhands/microagent/__init__.py @@ -0,0 +1,19 @@ +from .microagent import ( + BaseMicroAgent, + KnowledgeMicroAgent, + RepoMicroAgent, + TaskMicroAgent, + load_microagents_from_dir, +) +from .types import MicroAgentMetadata, MicroAgentType, TaskInput + +__all__ = [ + 'BaseMicroAgent', + 'KnowledgeMicroAgent', + 'RepoMicroAgent', + 'TaskMicroAgent', + 'MicroAgentMetadata', + 'MicroAgentType', + 'TaskInput', + 'load_microagents_from_dir', +] diff --git a/openhands/microagent/microagent.py b/openhands/microagent/microagent.py new file mode 100644 index 000000000000..1385436a422d --- /dev/null +++ b/openhands/microagent/microagent.py @@ -0,0 +1,164 @@ +import io +from pathlib import Path +from typing import Union + +import frontmatter +from pydantic import BaseModel + +from openhands.core.exceptions import ( + MicroAgentValidationError, +) +from openhands.microagent.types import MicroAgentMetadata, MicroAgentType + + +class BaseMicroAgent(BaseModel): + """Base class for all microagents.""" + + name: str + content: str + metadata: MicroAgentMetadata + source: str # path to the file + type: MicroAgentType + + @classmethod + def load( + cls, path: Union[str, Path], file_content: str | None = None + ) -> 'BaseMicroAgent': + """Load a microagent from a markdown file with frontmatter.""" + path = Path(path) if isinstance(path, str) else path + + # Only load directly from path if file_content is not provided + if file_content is None: + with open(path) as f: + file_content = f.read() + + # Legacy repo instructions are stored in .openhands_instructions + if path.name == '.openhands_instructions': + return RepoMicroAgent( + name='repo_legacy', + content=file_content, + metadata=MicroAgentMetadata(name='repo_legacy'), + source=str(path), + type=MicroAgentType.REPO_KNOWLEDGE, + ) + + file_io = io.StringIO(file_content) + loaded = frontmatter.load(file_io) + content = loaded.content + try: + metadata = MicroAgentMetadata(**loaded.metadata) + except Exception as e: + raise MicroAgentValidationError(f'Error loading metadata: {e}') from e + + # Create appropriate subclass based on type + subclass_map = { + MicroAgentType.KNOWLEDGE: KnowledgeMicroAgent, + MicroAgentType.REPO_KNOWLEDGE: RepoMicroAgent, + MicroAgentType.TASK: TaskMicroAgent, + } + if metadata.type not in subclass_map: + raise ValueError(f'Unknown microagent type: {metadata.type}') + + agent_class = subclass_map[metadata.type] + return agent_class( + name=metadata.name, + content=content, + metadata=metadata, + source=str(path), + type=metadata.type, + ) + + +class KnowledgeMicroAgent(BaseMicroAgent): + """Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations. They help with: + - Language best practices + - Framework guidelines + - Common patterns + - Tool usage + """ + + def __init__(self, **data): + super().__init__(**data) + if self.type != MicroAgentType.KNOWLEDGE: + raise ValueError('KnowledgeMicroAgent must have type KNOWLEDGE') + + def match_trigger(self, message: str) -> str | None: + """Match a trigger in the message. + + It returns the first trigger that matches the message. + """ + message = message.lower() + for trigger in self.triggers: + if trigger.lower() in message: + return trigger + return None + + @property + def triggers(self) -> list[str]: + return self.metadata.triggers + + +class RepoMicroAgent(BaseMicroAgent): + """MicroAgent specialized for repository-specific knowledge and guidelines. + + RepoMicroAgents are loaded from `.openhands/microagents/repo.md` files within repositories + and contain private, repository-specific instructions that are automatically loaded when + working with that repository. They are ideal for: + - Repository-specific guidelines + - Team practices and conventions + - Project-specific workflows + - Custom documentation references + """ + + def __init__(self, **data): + super().__init__(**data) + if self.type != MicroAgentType.REPO_KNOWLEDGE: + raise ValueError('RepoMicroAgent must have type REPO_KNOWLEDGE') + + +class TaskMicroAgent(BaseMicroAgent): + """MicroAgent specialized for task-based operations.""" + + def __init__(self, **data): + super().__init__(**data) + if self.type != MicroAgentType.TASK: + raise ValueError('TaskMicroAgent must have type TASK') + + +def load_microagents_from_dir( + microagent_dir: Union[str, Path], +) -> tuple[ + dict[str, RepoMicroAgent], dict[str, KnowledgeMicroAgent], dict[str, TaskMicroAgent] +]: + """Load all microagents from the given directory. + + Args: + microagent_dir: Path to the microagents directory. + + Returns: + Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries + """ + if isinstance(microagent_dir, str): + microagent_dir = Path(microagent_dir) + + repo_agents = {} + knowledge_agents = {} + task_agents = {} + + # Load all agents + for file in microagent_dir.rglob('*.md'): + # skip README.md + if file.name == 'README.md': + continue + try: + agent = BaseMicroAgent.load(file) + if isinstance(agent, RepoMicroAgent): + repo_agents[agent.name] = agent + elif isinstance(agent, KnowledgeMicroAgent): + knowledge_agents[agent.name] = agent + elif isinstance(agent, TaskMicroAgent): + task_agents[agent.name] = agent + except Exception as e: + raise ValueError(f'Error loading agent from {file}: {e}') + + return repo_agents, knowledge_agents, task_agents diff --git a/openhands/microagent/types.py b/openhands/microagent/types.py new file mode 100644 index 000000000000..0962553d93b2 --- /dev/null +++ b/openhands/microagent/types.py @@ -0,0 +1,29 @@ +from enum import Enum + +from pydantic import BaseModel, Field + + +class MicroAgentType(str, Enum): + """Type of microagent.""" + + KNOWLEDGE = 'knowledge' + REPO_KNOWLEDGE = 'repo' + TASK = 'task' + + +class MicroAgentMetadata(BaseModel): + """Metadata for all microagents.""" + + name: str = 'default' + type: MicroAgentType = Field(default=MicroAgentType.KNOWLEDGE) + version: str = Field(default='1.0.0') + agent: str = Field(default='CodeActAgent') + triggers: list[str] = [] # optional, only exists for knowledge microagents + + +class TaskInput(BaseModel): + """Input parameter for task-based agents.""" + + name: str + description: str + required: bool = True diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index ada82a36c5f1..672b9285ed7e 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -29,11 +29,18 @@ from openhands.events.observation import ( CmdOutputObservation, ErrorObservation, + FileReadObservation, NullObservation, Observation, UserRejectObservation, ) from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS +from openhands.microagent import ( + BaseMicroAgent, + KnowledgeMicroAgent, + RepoMicroAgent, + TaskMicroAgent, +) from openhands.runtime.plugins import ( JupyterRequirement, PluginRequirement, @@ -218,34 +225,73 @@ def clone_repo(self, github_token: str | None, selected_repository: str | None): self.log('info', f'Cloning repo: {selected_repository}') self.run_action(action) - def get_custom_microagents(self, selected_repository: str | None) -> list[str]: - custom_microagents_content = [] - custom_microagents_dir = Path('.openhands') / 'microagents' - - dir_name = str(custom_microagents_dir) + def get_microagents_from_selected_repo( + self, selected_repository: str | None + ) -> list[BaseMicroAgent]: + loaded_microagents: list[BaseMicroAgent] = [] + dir_name = Path('.openhands') / 'microagents' if selected_repository: - dir_name = str( - Path(selected_repository.split('/')[1]) / custom_microagents_dir - ) + dir_name = Path('/workspace') / selected_repository.split('/')[1] / dir_name + + # Legacy Repo Instructions + # Check for legacy .openhands_instructions file obs = self.read(FileReadAction(path='.openhands_instructions')) if isinstance(obs, ErrorObservation): - self.log('debug', 'openhands_instructions not present') - else: - openhands_instructions = obs.content - self.log('info', f'openhands_instructions: {openhands_instructions}') - custom_microagents_content.append(openhands_instructions) - - files = self.list_files(dir_name) - - self.log('info', f'Found {len(files)} custom microagents.') + self.log( + 'debug', + f'openhands_instructions not present, trying to load from {dir_name}', + ) + obs = self.read( + FileReadAction(path=str(dir_name / '.openhands_instructions')) + ) - for fname in files: - content = self.read( - FileReadAction(path=str(custom_microagents_dir / fname)) - ).content - custom_microagents_content.append(content) + if isinstance(obs, FileReadObservation): + self.log('info', 'openhands_instructions microagent loaded.') + loaded_microagents.append( + BaseMicroAgent.load( + path='.openhands_instructions', file_content=obs.content + ) + ) - return custom_microagents_content + # Check for local repository microagents + files = self.list_files(str(dir_name)) + self.log('info', f'Found {len(files)} local microagents.') + if 'repo.md' in files: + obs = self.read(FileReadAction(path=str(dir_name / 'repo.md'))) + if isinstance(obs, FileReadObservation): + self.log('info', 'repo.md microagent loaded.') + loaded_microagents.append( + RepoMicroAgent.load( + path=str(dir_name / 'repo.md'), file_content=obs.content + ) + ) + + if 'knowledge' in files: + knowledge_dir = dir_name / 'knowledge' + _knowledge_microagents_files = self.list_files(str(knowledge_dir)) + for fname in _knowledge_microagents_files: + obs = self.read(FileReadAction(path=str(knowledge_dir / fname))) + if isinstance(obs, FileReadObservation): + self.log('info', f'knowledge/{fname} microagent loaded.') + loaded_microagents.append( + KnowledgeMicroAgent.load( + path=str(knowledge_dir / fname), file_content=obs.content + ) + ) + + if 'tasks' in files: + tasks_dir = dir_name / 'tasks' + _tasks_microagents_files = self.list_files(str(tasks_dir)) + for fname in _tasks_microagents_files: + obs = self.read(FileReadAction(path=str(tasks_dir / fname))) + if isinstance(obs, FileReadObservation): + self.log('info', f'tasks/{fname} microagent loaded.') + loaded_microagents.append( + TaskMicroAgent.load( + path=str(tasks_dir / fname), file_content=obs.content + ) + ) + return loaded_microagents def run_action(self, action: Action) -> Observation: """Run an action and return the resulting observation. diff --git a/openhands/server/app.py b/openhands/server/app.py index d6c3e8aa5611..7d8a83db3797 100644 --- a/openhands/server/app.py +++ b/openhands/server/app.py @@ -22,7 +22,9 @@ from openhands.server.routes.feedback import app as feedback_api_router from openhands.server.routes.files import app as files_api_router from openhands.server.routes.github import app as github_api_router -from openhands.server.routes.new_conversation import app as new_conversation_api_router +from openhands.server.routes.manage_conversations import ( + app as manage_conversation_api_router, +) from openhands.server.routes.public import app as public_api_router from openhands.server.routes.security import app as security_api_router from openhands.server.routes.settings import app as settings_router @@ -60,7 +62,7 @@ async def health(): app.include_router(security_api_router) app.include_router(feedback_api_router) app.include_router(conversation_api_router) -app.include_router(new_conversation_api_router) +app.include_router(manage_conversation_api_router) app.include_router(settings_router) app.include_router(github_api_router) diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py index 46fac1ead29c..a7b30e479bbc 100644 --- a/openhands/server/middleware.py +++ b/openhands/server/middleware.py @@ -121,7 +121,7 @@ def _should_attach(self, request) -> bool: if request.url.path.startswith('/api/conversation'): # FIXME: we should be able to use path_params path_parts = request.url.path.split('/') - if len(path_parts) > 3: + if len(path_parts) > 4: conversation_id = request.url.path.split('/')[3] if not conversation_id: return False diff --git a/openhands/server/mock/listen.py b/openhands/server/mock/listen.py index 30aaef68589a..d5e51585a982 100644 --- a/openhands/server/mock/listen.py +++ b/openhands/server/mock/listen.py @@ -49,7 +49,6 @@ def read_llm_models(): def read_llm_agents(): return [ 'CodeActAgent', - 'PlannerAgent', ] diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py new file mode 100644 index 000000000000..0d375229a50b --- /dev/null +++ b/openhands/server/routes/manage_conversations.py @@ -0,0 +1,224 @@ +import uuid +from datetime import datetime +from typing import Callable + +from fastapi import APIRouter, Body, Request +from fastapi.responses import JSONResponse +from github import Github +from pydantic import BaseModel + +from openhands.core.logger import openhands_logger as logger +from openhands.events.stream import EventStreamSubscriber +from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl +from openhands.server.session.conversation_init_data import ConversationInitData +from openhands.server.shared import config, session_manager +from openhands.storage.data_models.conversation_info import ConversationInfo +from openhands.storage.data_models.conversation_info_result_set import ( + ConversationInfoResultSet, +) +from openhands.storage.data_models.conversation_metadata import ConversationMetadata +from openhands.storage.data_models.conversation_status import ConversationStatus +from openhands.utils.async_utils import ( + GENERAL_TIMEOUT, + call_async_from_sync, + call_sync_from_async, + wait_all, +) + +app = APIRouter(prefix='/api') +UPDATED_AT_CALLBACK_ID = 'updated_at_callback_id' + + +class InitSessionRequest(BaseModel): + github_token: str | None = None + latest_event_id: int = -1 + selected_repository: str | None = None + args: dict | None = None + + +@app.post('/conversations') +async def new_conversation(request: Request, data: InitSessionRequest): + """Initialize a new session or join an existing one. + After successful initialization, the client should connect to the WebSocket + using the returned conversation ID + """ + logger.info('Initializing new conversation') + github_token = data.github_token or '' + + logger.info('Loading settings') + settings_store = await SettingsStoreImpl.get_instance(config, github_token) + settings = await settings_store.load() + logger.info('Settings loaded') + + session_init_args: dict = {} + if settings: + session_init_args = {**settings.__dict__, **session_init_args} + + session_init_args['github_token'] = github_token + session_init_args['selected_repository'] = data.selected_repository + conversation_init_data = ConversationInitData(**session_init_args) + logger.info('Loading conversation store') + conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + logger.info('Conversation store loaded') + + conversation_id = uuid.uuid4().hex + while await conversation_store.exists(conversation_id): + logger.warning(f'Collision on conversation ID: {conversation_id}. Retrying...') + conversation_id = uuid.uuid4().hex + logger.info(f'New conversation ID: {conversation_id}') + + user_id = '' + if data.github_token: + logger.info('Fetching Github user ID') + with Github(data.github_token) as g: + gh_user = await call_sync_from_async(g.get_user) + user_id = gh_user.id + + logger.info(f'Saving metadata for conversation {conversation_id}') + await conversation_store.save_metadata( + ConversationMetadata( + conversation_id=conversation_id, + github_user_id=user_id, + selected_repository=data.selected_repository, + ) + ) + + logger.info(f'Starting agent loop for conversation {conversation_id}') + event_stream = await session_manager.maybe_start_agent_loop( + conversation_id, conversation_init_data + ) + try: + event_stream.subscribe( + EventStreamSubscriber.SERVER, + _create_conversation_update_callback( + data.github_token or '', conversation_id + ), + UPDATED_AT_CALLBACK_ID, + ) + except ValueError: + pass # Already subscribed - take no action + logger.info(f'Finished initializing conversation {conversation_id}') + return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id}) + + +@app.get('/conversations') +async def search_conversations( + request: Request, + page_id: str | None = None, + limit: int = 20, +) -> ConversationInfoResultSet: + github_token = getattr(request.state, 'github_token', '') or '' + conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation_metadata_result_set = await conversation_store.search(page_id, limit) + conversation_ids = set( + conversation.conversation_id + for conversation in conversation_metadata_result_set.results + ) + running_conversations = await session_manager.get_agent_loop_running( + set(conversation_ids) + ) + result = ConversationInfoResultSet( + results=await wait_all( + _get_conversation_info( + conversation=conversation, + is_running=conversation.conversation_id in running_conversations, + ) + for conversation in conversation_metadata_result_set.results + ), + next_page_id=conversation_metadata_result_set.next_page_id, + ) + return result + + +@app.get('/conversations/{conversation_id}') +async def get_conversation( + conversation_id: str, request: Request +) -> ConversationInfo | None: + github_token = getattr(request.state, 'github_token', '') or '' + conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + try: + metadata = await conversation_store.get_metadata(conversation_id) + is_running = await session_manager.is_agent_loop_running(conversation_id) + conversation_info = await _get_conversation_info(metadata, is_running) + return conversation_info + except FileNotFoundError: + return None + + +@app.patch('/conversations/{conversation_id}') +async def update_conversation( + request: Request, conversation_id: str, title: str = Body(embed=True) +) -> bool: + github_token = getattr(request.state, 'github_token', '') or '' + conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + metadata = await conversation_store.get_metadata(conversation_id) + if not metadata: + return False + metadata.title = title + await conversation_store.save_metadata(metadata) + return True + + +@app.delete('/conversations/{conversation_id}') +async def delete_conversation( + conversation_id: str, + request: Request, +) -> bool: + github_token = getattr(request.state, 'github_token', '') or '' + conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + try: + await conversation_store.get_metadata(conversation_id) + except FileNotFoundError: + return False + is_running = await session_manager.is_agent_loop_running(conversation_id) + if is_running: + return False + await conversation_store.delete_metadata(conversation_id) + return True + + +async def _get_conversation_info( + conversation: ConversationMetadata, + is_running: bool, +) -> ConversationInfo | None: + try: + title = conversation.title + if not title: + title = f'Conversation {conversation.conversation_id[:5]}' + return ConversationInfo( + conversation_id=conversation.conversation_id, + title=title, + last_updated_at=conversation.last_updated_at, + selected_repository=conversation.selected_repository, + status=ConversationStatus.RUNNING + if is_running + else ConversationStatus.STOPPED, + ) + except Exception: # type: ignore + logger.warning( + f'Error loading conversation: {conversation.conversation_id[:5]}', + exc_info=True, + stack_info=True, + ) + return None + + +def _create_conversation_update_callback( + github_token: str, conversation_id: str +) -> Callable: + def callback(*args, **kwargs): + call_async_from_sync( + _update_timestamp_for_conversation, + GENERAL_TIMEOUT, + github_token, + conversation_id, + ) + + return callback + + +async def _update_timestamp_for_conversation(github_token: str, conversation_id: str): + conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation = await conversation_store.get_metadata(conversation_id) + conversation.last_updated_at = datetime.now() + await conversation_store.save_metadata(conversation) diff --git a/openhands/server/routes/new_conversation.py b/openhands/server/routes/new_conversation.py deleted file mode 100644 index 09394c209183..000000000000 --- a/openhands/server/routes/new_conversation.py +++ /dev/null @@ -1,80 +0,0 @@ -import uuid - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse -from github import Github -from pydantic import BaseModel - -from openhands.core.logger import openhands_logger as logger -from openhands.server.data_models.conversation_metadata import ConversationMetadata -from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl -from openhands.server.session.conversation_init_data import ConversationInitData -from openhands.server.shared import config, session_manager -from openhands.utils.async_utils import call_sync_from_async - -app = APIRouter(prefix='/api') - - -class InitSessionRequest(BaseModel): - github_token: str | None = None - latest_event_id: int = -1 - selected_repository: str | None = None - args: dict | None = None - - -@app.post('/conversations') -async def new_conversation(request: Request, data: InitSessionRequest): - """Initialize a new session or join an existing one. - After successful initialization, the client should connect to the WebSocket - using the returned conversation ID - """ - logger.info('Initializing new conversation') - github_token = '' - if data.github_token: - github_token = data.github_token - - logger.info('Loading settings') - settings_store = await SettingsStoreImpl.get_instance(config, github_token) - settings = await settings_store.load() - logger.info('Settings loaded') - - session_init_args: dict = {} - if settings: - session_init_args = {**settings.__dict__, **session_init_args} - - session_init_args['github_token'] = github_token - session_init_args['selected_repository'] = data.selected_repository - conversation_init_data = ConversationInitData(**session_init_args) - - logger.info('Loading conversation store') - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) - logger.info('Conversation store loaded') - - conversation_id = uuid.uuid4().hex - while await conversation_store.exists(conversation_id): - logger.warning(f'Collision on conversation ID: {conversation_id}. Retrying...') - conversation_id = uuid.uuid4().hex - logger.info(f'New conversation ID: {conversation_id}') - - user_id = '' - if data.github_token: - logger.info('Fetching Github user ID') - with Github(data.github_token) as g: - gh_user = await call_sync_from_async(g.get_user) - user_id = gh_user.id - - logger.info(f'Saving metadata for conversation {conversation_id}') - await conversation_store.save_metadata( - ConversationMetadata( - conversation_id=conversation_id, - github_user_id=user_id, - selected_repository=data.selected_repository, - ) - ) - - logger.info(f'Starting agent loop for conversation {conversation_id}') - await session_manager.maybe_start_agent_loop( - conversation_id, conversation_init_data - ) - logger.info(f'Finished initializing conversation {conversation_id}') - return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id}) diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 81637e8e45ef..4fcdb42f02ba 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -18,12 +18,8 @@ @app.get('/settings') -async def load_settings( - request: Request, -) -> Settings | None: - github_token = '' - if hasattr(request.state, 'github_token'): - github_token = request.state.github_token +async def load_settings(request: Request) -> Settings | None: + github_token = getattr(request.state, 'github_token', '') or '' try: settings_store = await SettingsStoreImpl.get_instance(config, github_token) settings = await settings_store.load() diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 369e105d93b3..74d394b7d9f6 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -12,6 +12,7 @@ from openhands.events.action import ChangeAgentStateAction from openhands.events.event import EventSource from openhands.events.stream import EventStream +from openhands.microagent import BaseMicroAgent from openhands.runtime import get_runtime_cls from openhands.runtime.base import Runtime from openhands.security import SecurityAnalyzer, options @@ -209,10 +210,10 @@ async def _create_runtime( self.runtime.clone_repo(github_token, selected_repository) if agent.prompt_manager: - microagents = await call_sync_from_async( - self.runtime.get_custom_microagents, selected_repository + microagents: list[BaseMicroAgent] = await call_sync_from_async( + self.runtime.get_microagents_from_selected_repo, selected_repository ) - agent.prompt_manager.load_microagent_files(microagents) + agent.prompt_manager.load_microagents(microagents) logger.debug( f'Runtime initialized with plugins: {[plugin.name for plugin in self.runtime.plugins]}' diff --git a/openhands/storage/conversation/conversation_store.py b/openhands/storage/conversation/conversation_store.py index 41d9452ab8f8..2a09322574bf 100644 --- a/openhands/storage/conversation/conversation_store.py +++ b/openhands/storage/conversation/conversation_store.py @@ -3,7 +3,10 @@ from abc import ABC, abstractmethod from openhands.core.config.app_config import AppConfig -from openhands.server.data_models.conversation_metadata import ConversationMetadata +from openhands.storage.data_models.conversation_metadata import ConversationMetadata +from openhands.storage.data_models.conversation_metadata_result_set import ( + ConversationMetadataResultSet, +) class ConversationStore(ABC): @@ -19,10 +22,22 @@ async def save_metadata(self, metadata: ConversationMetadata): async def get_metadata(self, conversation_id: str) -> ConversationMetadata: """Load conversation metadata""" + @abstractmethod + async def delete_metadata(self, conversation_id: str) -> None: + """delete conversation metadata""" + @abstractmethod async def exists(self, conversation_id: str) -> bool: """Check if conversation exists""" + @abstractmethod + async def search( + self, + page_id: str | None = None, + limit: int = 20, + ) -> ConversationMetadataResultSet: + """Search conversations""" + @classmethod @abstractmethod async def get_instance( diff --git a/openhands/storage/conversation/file_conversation_store.py b/openhands/storage/conversation/file_conversation_store.py index b77555fcd51e..da9b584a521a 100644 --- a/openhands/storage/conversation/file_conversation_store.py +++ b/openhands/storage/conversation/file_conversation_store.py @@ -1,15 +1,26 @@ from __future__ import annotations -import json from dataclasses import dataclass +from pydantic import TypeAdapter + from openhands.core.config.app_config import AppConfig +from openhands.core.logger import openhands_logger as logger from openhands.storage import get_file_store from openhands.storage.conversation.conversation_store import ConversationStore -from openhands.server.data_models.conversation_metadata import ConversationMetadata +from openhands.storage.data_models.conversation_metadata import ConversationMetadata +from openhands.storage.data_models.conversation_metadata_result_set import ( + ConversationMetadataResultSet, +) from openhands.storage.files import FileStore -from openhands.storage.locations import get_conversation_metadata_filename +from openhands.storage.locations import ( + CONVERSATION_BASE_DIR, + get_conversation_metadata_filename, +) from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.search_utils import offset_to_page_id, page_id_to_offset + +conversation_metadata_type_adapter = TypeAdapter(ConversationMetadata) @dataclass @@ -17,14 +28,19 @@ class FileConversationStore(ConversationStore): file_store: FileStore async def save_metadata(self, metadata: ConversationMetadata): - json_str = json.dumps(metadata.__dict__) + json_str = conversation_metadata_type_adapter.dump_json(metadata) path = self.get_conversation_metadata_filename(metadata.conversation_id) await call_sync_from_async(self.file_store.write, path, json_str) async def get_metadata(self, conversation_id: str) -> ConversationMetadata: path = self.get_conversation_metadata_filename(conversation_id) json_str = await call_sync_from_async(self.file_store.read, path) - return ConversationMetadata(**json.loads(json_str)) + result = conversation_metadata_type_adapter.validate_json(json_str) + return result + + async def delete_metadata(self, conversation_id: str) -> None: + path = self.get_conversation_metadata_filename(conversation_id) + await call_sync_from_async(self.file_store.delete, path) async def exists(self, conversation_id: str) -> bool: path = self.get_conversation_metadata_filename(conversation_id) @@ -34,6 +50,41 @@ async def exists(self, conversation_id: str) -> bool: except FileNotFoundError: return False + async def search( + self, + page_id: str | None = None, + limit: int = 20, + ) -> ConversationMetadataResultSet: + conversations: list[ConversationMetadata] = [] + metadata_dir = self.get_conversation_metadata_dir() + try: + conversation_ids = [ + path.split('/')[-2] + for path in self.file_store.list(metadata_dir) + if not path.startswith(f'{metadata_dir}/.') + ] + except FileNotFoundError: + return ConversationMetadataResultSet([]) + num_conversations = len(conversation_ids) + start = page_id_to_offset(page_id) + end = min(limit + start, num_conversations) + conversation_ids = conversation_ids[start:end] + conversations = [] + for conversation_id in conversation_ids: + try: + conversations.append(await self.get_metadata(conversation_id)) + except Exception: + logger.warning( + f'Error loading conversation: {conversation_id}', + exc_info=True, + stack_info=True, + ) + next_page_id = offset_to_page_id(end, end < num_conversations) + return ConversationMetadataResultSet(conversations, next_page_id) + + def get_conversation_metadata_dir(self) -> str: + return CONVERSATION_BASE_DIR + def get_conversation_metadata_filename(self, conversation_id: str) -> str: return get_conversation_metadata_filename(conversation_id) diff --git a/openhands/storage/data_models/conversation_info.py b/openhands/storage/data_models/conversation_info.py new file mode 100644 index 000000000000..cda7a2653bff --- /dev/null +++ b/openhands/storage/data_models/conversation_info.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from datetime import datetime + +from openhands.storage.data_models.conversation_status import ConversationStatus + + +@dataclass +class ConversationInfo: + """Information about a conversation""" + + conversation_id: str + title: str + last_updated_at: datetime | None = None + status: ConversationStatus = ConversationStatus.STOPPED + selected_repository: str | None = None diff --git a/openhands/storage/data_models/conversation_info_result_set.py b/openhands/storage/data_models/conversation_info_result_set.py new file mode 100644 index 000000000000..b153baf1a290 --- /dev/null +++ b/openhands/storage/data_models/conversation_info_result_set.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass, field + +from openhands.storage.data_models.conversation_info import ConversationInfo + + +@dataclass +class ConversationInfoResultSet: + results: list[ConversationInfo] = field(default_factory=list) + next_page_id: str | None = None diff --git a/openhands/server/data_models/conversation_metadata.py b/openhands/storage/data_models/conversation_metadata.py similarity index 50% rename from openhands/server/data_models/conversation_metadata.py rename to openhands/storage/data_models/conversation_metadata.py index 8aa43a623bd9..21c84be99eba 100644 --- a/openhands/server/data_models/conversation_metadata.py +++ b/openhands/storage/data_models/conversation_metadata.py @@ -1,8 +1,11 @@ from dataclasses import dataclass +from datetime import datetime @dataclass class ConversationMetadata: conversation_id: str - github_user_id: str + github_user_id: int | str selected_repository: str | None + title: str | None = None + last_updated_at: datetime | None = None diff --git a/openhands/storage/data_models/conversation_metadata_result_set.py b/openhands/storage/data_models/conversation_metadata_result_set.py new file mode 100644 index 000000000000..6f93827258c3 --- /dev/null +++ b/openhands/storage/data_models/conversation_metadata_result_set.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass, field + +from openhands.storage.data_models.conversation_metadata import ConversationMetadata + + +@dataclass +class ConversationMetadataResultSet: + results: list[ConversationMetadata] = field(default_factory=list) + next_page_id: str | None = None diff --git a/openhands/storage/data_models/conversation_status.py b/openhands/storage/data_models/conversation_status.py new file mode 100644 index 000000000000..8d476fa4e3a5 --- /dev/null +++ b/openhands/storage/data_models/conversation_status.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class ConversationStatus(Enum): + RUNNING = 'RUNNING' + STOPPED = 'STOPPED' diff --git a/openhands/utils/microagent.py b/openhands/utils/microagent.py deleted file mode 100644 index a2e492e60253..000000000000 --- a/openhands/utils/microagent.py +++ /dev/null @@ -1,54 +0,0 @@ -import os - -import frontmatter -import pydantic - - -class MicroAgentMetadata(pydantic.BaseModel): - name: str = 'default' - agent: str = '' - triggers: list[str] = [] - - -class MicroAgent: - def __init__(self, path: str | None = None, content: str | None = None): - if path and not content: - self.path = path - if not os.path.exists(path): - raise FileNotFoundError(f'Micro agent file {path} is not found') - with open(path, 'r') as file: - loaded = frontmatter.load(file) - self._content = loaded.content - self._metadata = MicroAgentMetadata(**loaded.metadata) - elif content and not path: - metadata, self._content = frontmatter.parse(content) - self._metadata = MicroAgentMetadata(**metadata) - else: - raise Exception('You must pass either path or file content, but not both.') - - def get_trigger(self, message: str) -> str | None: - message = message.lower() - for trigger in self.triggers: - if trigger.lower() in message: - return trigger - return None - - @property - def content(self) -> str: - return self._content - - @property - def metadata(self) -> MicroAgentMetadata: - return self._metadata - - @property - def name(self) -> str: - return self._metadata.name - - @property - def triggers(self) -> list[str]: - return self._metadata.triggers - - @property - def agent(self) -> str: - return self._metadata.agent diff --git a/openhands/utils/prompt.py b/openhands/utils/prompt.py index 90be7f2d582d..b71e57d4e43e 100644 --- a/openhands/utils/prompt.py +++ b/openhands/utils/prompt.py @@ -5,7 +5,12 @@ from openhands.controller.state.state import State from openhands.core.message import Message, TextContent -from openhands.utils.microagent import MicroAgent +from openhands.microagent import ( + BaseMicroAgent, + KnowledgeMicroAgent, + RepoMicroAgent, + load_microagents_from_dir, +) class PromptManager: @@ -29,31 +34,44 @@ def __init__( agent_skills_docs: str = '', disabled_microagents: list[str] | None = None, ): + self.disabled_microagents: list[str] = disabled_microagents or [] self.prompt_dir: str = prompt_dir self.agent_skills_docs: str = agent_skills_docs self.system_template: Template = self._load_template('system_prompt') self.user_template: Template = self._load_template('user_prompt') - self.microagents: dict = {} - microagent_files = [] + self.knowledge_microagents: dict[str, KnowledgeMicroAgent] = {} + self.repo_microagents: dict[str, RepoMicroAgent] = {} + if microagent_dir: - microagent_files = [ - os.path.join(microagent_dir, f) - for f in os.listdir(microagent_dir) - if f.endswith('.md') - ] - for microagent_file in microagent_files: - microagent = MicroAgent(path=microagent_file) - if ( - disabled_microagents is None - or microagent.name not in disabled_microagents - ): - self.microagents[microagent.name] = microagent - - def load_microagent_files(self, microagent_files: list[str]): - for microagent_file in microagent_files: - microagent = MicroAgent(content=microagent_file) - self.microagents[microagent.name] = microagent + # Only load KnowledgeMicroAgents + repo_microagents, knowledge_microagents, _ = load_microagents_from_dir( + microagent_dir + ) + assert all( + isinstance(microagent, KnowledgeMicroAgent) + for microagent in knowledge_microagents.values() + ) + for name, microagent in knowledge_microagents.items(): + if name not in self.disabled_microagents: + self.knowledge_microagents[name] = microagent + assert all( + isinstance(microagent, RepoMicroAgent) + for microagent in repo_microagents.values() + ) + for name, microagent in repo_microagents.items(): + if name not in self.disabled_microagents: + self.repo_microagents[name] = microagent + + def load_microagents(self, microagents: list[BaseMicroAgent]): + # Only keep KnowledgeMicroAgents and RepoMicroAgents + for microagent in microagents: + if microagent.name in self.disabled_microagents: + continue + if isinstance(microagent, KnowledgeMicroAgent): + self.knowledge_microagents[microagent.name] = microagent + elif isinstance(microagent, RepoMicroAgent): + self.repo_microagents[microagent.name] = microagent def _load_template(self, template_name: str) -> Template: if self.prompt_dir is None: @@ -91,9 +109,9 @@ def enhance_message(self, message: Message) -> None: if not message.content: return message_content = message.content[0].text - for microagent in self.microagents.values(): - trigger = microagent.get_trigger(message_content) - if trigger and trigger != 'github': + for microagent in self.knowledge_microagents.values(): + trigger = microagent.match_trigger(message_content) + if trigger: micro_text = f'\nThe following information has been included based on a keyword match for "{trigger}". It may or may not be relevant to the user\'s request.' micro_text += '\n\n' + microagent.content micro_text += '\n' diff --git a/openhands/utils/search_utils.py b/openhands/utils/search_utils.py new file mode 100644 index 000000000000..315d0775c0c4 --- /dev/null +++ b/openhands/utils/search_utils.py @@ -0,0 +1,15 @@ +import base64 + + +def offset_to_page_id(offset: int, has_next: bool) -> str | None: + if not has_next: + return None + next_page_id = base64.b64encode(str(offset).encode()).decode() + return next_page_id + + +def page_id_to_offset(page_id: str | None) -> int: + if not page_id: + return 0 + offset = int(base64.b64decode(page_id).decode()) + return offset diff --git a/poetry.lock b/poetry.lock index cbf5f1dc12b5..6afb8ff5dfb3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3256,13 +3256,13 @@ files = [ [[package]] name = "json-repair" -version = "0.34.0" +version = "0.35.0" description = "A package to repair broken json strings" optional = false python-versions = ">=3.9" files = [ - {file = "json_repair-0.34.0-py3-none-any.whl", hash = "sha256:a0bb0d3993838b320adf6c82c11c92419d3df794901689bffb3abed208472adf"}, - {file = "json_repair-0.34.0.tar.gz", hash = "sha256:401d454e039e24425659cfb41e1a7a3800123abfb0d81653282585dc289862cb"}, + {file = "json_repair-0.35.0-py3-none-any.whl", hash = "sha256:1d429407158474d28a996e745b8f8f7dc78957cb2cfbc92120b9f580b5230a9e"}, + {file = "json_repair-0.35.0.tar.gz", hash = "sha256:e70f834865a4ae5fe64352c23c1c16d3b70c5dd62dc544a169d8b0932bdbdcaa"}, ] [[package]] @@ -3739,13 +3739,13 @@ types-tqdm = "*" [[package]] name = "litellm" -version = "1.56.4" +version = "1.56.6" description = "Library to easily interface with LLM API providers" optional = false python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" files = [ - {file = "litellm-1.56.4-py3-none-any.whl", hash = "sha256:699a8db46f7de045069a77c435e13244b5fdaf5df1c8cb5e6ad675ef7e104ccd"}, - {file = "litellm-1.56.4.tar.gz", hash = "sha256:2808ca21878d200f7676a3d11e5bf2b5e3349ae504628f279cd7297c7dbd2038"}, + {file = "litellm-1.56.6-py3-none-any.whl", hash = "sha256:dc04becae6b09b401edfc13e9e648443e425a52c1d7217351c7841811dc8dbec"}, + {file = "litellm-1.56.6.tar.gz", hash = "sha256:24612fff40f31044257c16bc29aa086cbb084b830e427a19f4adb96deeea626d"}, ] [package.dependencies] @@ -4614,12 +4614,12 @@ type = ["mypy (==1.11.2)"] [[package]] name = "modal" -version = "0.70.2" +version = "0.70.3" description = "Python client library for Modal" optional = false python-versions = ">=3.9" files = [ - {file = "modal-0.70.2-py3-none-any.whl", hash = "sha256:14cd112313ab8f364fe6252795b872805ad6c91945e1216d0984dd43d89b2f57"}, + {file = "modal-0.70.3-py3-none-any.whl", hash = "sha256:9a39f59358d07d8b884e244814eacbad5d66fcbe2b42e7a61e8759226825dcd4"}, ] [package.dependencies] @@ -4827,43 +4827,49 @@ dill = ">=0.3.8" [[package]] name = "mypy" -version = "1.14.0" +version = "1.14.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, - {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, - {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"}, - {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"}, - {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"}, - {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"}, - {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"}, - {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"}, - {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"}, - {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"}, - {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"}, - {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"}, - {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"}, - {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"}, - {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"}, - {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"}, - {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"}, - {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"}, - {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"}, - {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"}, - {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"}, - {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"}, - {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"}, - {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"}, - {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"}, - {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"}, - {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"}, - {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"}, - {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"}, - {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"}, - {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"}, - {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, + {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, + {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, + {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, + {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, + {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, + {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, + {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, + {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, ] [package.dependencies] @@ -6846,13 +6852,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.25.0" +version = "0.25.1" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" files = [ - {file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"}, - {file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"}, + {file = "pytest_asyncio-0.25.1-py3-none-any.whl", hash = "sha256:c84878849ec63ff2ca509423616e071ef9cd8cc93c053aa33b5b8fb70a990671"}, + {file = "pytest_asyncio-0.25.1.tar.gz", hash = "sha256:79be8a72384b0c917677e00daa711e07db15259f4d23203c59012bcd989d4aee"}, ] [package.dependencies] @@ -7656,29 +7662,29 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.8.4" +version = "0.8.5" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60"}, - {file = "ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac"}, - {file = "ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf"}, - {file = "ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720"}, - {file = "ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae"}, - {file = "ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7"}, - {file = "ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111"}, - {file = "ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8"}, - {file = "ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835"}, - {file = "ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d"}, - {file = "ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08"}, - {file = "ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8"}, + {file = "ruff-0.8.5-py3-none-linux_armv6l.whl", hash = "sha256:5ad11a5e3868a73ca1fa4727fe7e33735ea78b416313f4368c504dbeb69c0f88"}, + {file = "ruff-0.8.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f69ab37771ea7e0715fead8624ec42996d101269a96e31f4d31be6fc33aa19b7"}, + {file = "ruff-0.8.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b5462d7804558ccff9c08fe8cbf6c14b7efe67404316696a2dde48297b1925bb"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d56de7220a35607f9fe59f8a6d018e14504f7b71d784d980835e20fc0611cd50"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9d99cf80b0429cbebf31cbbf6f24f05a29706f0437c40413d950e67e2d4faca4"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b75ac29715ac60d554a049dbb0ef3b55259076181c3369d79466cb130eb5afd"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9d526a62c9eda211b38463528768fd0ada25dad524cb33c0e99fcff1c67b5dc"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:587c5e95007612c26509f30acc506c874dab4c4abbacd0357400bd1aa799931b"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:622b82bf3429ff0e346835ec213aec0a04d9730480cbffbb6ad9372014e31bbd"}, + {file = "ruff-0.8.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f99be814d77a5dac8a8957104bdd8c359e85c86b0ee0e38dca447cb1095f70fb"}, + {file = "ruff-0.8.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01c048f9c3385e0fd7822ad0fd519afb282af9cf1778f3580e540629df89725"}, + {file = "ruff-0.8.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7512e8cb038db7f5db6aae0e24735ff9ea03bb0ed6ae2ce534e9baa23c1dc9ea"}, + {file = "ruff-0.8.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:762f113232acd5b768d6b875d16aad6b00082add40ec91c927f0673a8ec4ede8"}, + {file = "ruff-0.8.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:03a90200c5dfff49e4c967b405f27fdfa81594cbb7c5ff5609e42d7fe9680da5"}, + {file = "ruff-0.8.5-py3-none-win32.whl", hash = "sha256:8710ffd57bdaa6690cbf6ecff19884b8629ec2a2a2a2f783aa94b1cc795139ed"}, + {file = "ruff-0.8.5-py3-none-win_amd64.whl", hash = "sha256:4020d8bf8d3a32325c77af452a9976a9ad6455773bcb94991cf15bd66b347e47"}, + {file = "ruff-0.8.5-py3-none-win_arm64.whl", hash = "sha256:134ae019ef13e1b060ab7136e7828a6d83ea727ba123381307eb37c6bd5e01cb"}, + {file = "ruff-0.8.5.tar.gz", hash = "sha256:1098d36f69831f7ff2a1da3e6407d5fbd6dfa2559e4f74ff2d260c5588900317"}, ] [[package]] @@ -10048,4 +10054,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "96bbb4630f74c240620aa5bc7801f195599368bd921cdf1a5fed4339d0cb19c3" +content-hash = "691bdd0f64e3476858eb34ce6ed6d0b0e7d97458cfd69fd366cd9c1c4f4ec897" diff --git a/pyproject.toml b/pyproject.toml index 155f62b8c764..be7cb9a3a7ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openhands-ai" -version = "0.17.0" +version = "0.18.0" description = "OpenHands: Code Less, Make More" authors = ["OpenHands"] license = "MIT" @@ -80,8 +80,8 @@ voyageai = "*" llama-index-embeddings-voyageai = "*" [tool.poetry.group.dev.dependencies] -ruff = "0.8.4" -mypy = "1.14.0" +ruff = "0.8.5" +mypy = "1.14.1" pre-commit = "4.0.1" build = "*" @@ -100,7 +100,6 @@ reportlab = "*" [tool.coverage.run] concurrency = ["gevent"] - [tool.poetry.group.runtime.dependencies] jupyterlab = "*" notebook = "*" @@ -130,7 +129,6 @@ ignore = ["D1"] [tool.ruff.lint.pydocstyle] convention = "google" - [tool.poetry.group.evaluation.dependencies] streamlit = "*" whatthepatch = "*" diff --git a/tests/unit/test_action_serialization.py b/tests/unit/test_action_serialization.py index 93c537937ed0..318dd612a2d7 100644 --- a/tests/unit/test_action_serialization.py +++ b/tests/unit/test_action_serialization.py @@ -1,6 +1,5 @@ from openhands.events.action import ( Action, - AddTaskAction, AgentFinishAction, AgentRejectAction, BrowseInteractiveAction, @@ -9,7 +8,6 @@ FileReadAction, FileWriteAction, MessageAction, - ModifyTaskAction, ) from openhands.events.action.action import ActionConfirmationStatus from openhands.events.serialization import ( @@ -156,24 +154,3 @@ def test_file_write_action_serialization_deserialization(): }, } serialization_deserialization(original_action_dict, FileWriteAction) - - -def test_add_task_action_serialization_deserialization(): - original_action_dict = { - 'action': 'add_task', - 'args': { - 'parent': 'Test parent', - 'goal': 'Test goal', - 'subtasks': [], - 'thought': '', - }, - } - serialization_deserialization(original_action_dict, AddTaskAction) - - -def test_modify_task_action_serialization_deserialization(): - original_action_dict = { - 'action': 'modify_task', - 'args': {'task_id': 1, 'state': 'Test state.', 'thought': ''}, - } - serialization_deserialization(original_action_dict, ModifyTaskAction) diff --git a/tests/unit/test_codeact_agent.py b/tests/unit/test_codeact_agent.py index bc5d772442ab..28696df7d2a7 100644 --- a/tests/unit/test_codeact_agent.py +++ b/tests/unit/test_codeact_agent.py @@ -39,7 +39,8 @@ @pytest.fixture def agent() -> CodeActAgent: - agent = CodeActAgent(llm=LLM(LLMConfig()), config=AgentConfig()) + config = AgentConfig() + agent = CodeActAgent(llm=LLM(LLMConfig()), config=config) agent.llm = Mock() agent.llm.config = Mock() agent.llm.config.max_message_chars = 100 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d4ef11c4ce8c..d1306ca63114 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,4 +1,6 @@ +import logging import os +from io import StringIO import pytest @@ -11,6 +13,7 @@ load_from_env, load_from_toml, ) +from openhands.core.logger import openhands_logger @pytest.fixture @@ -71,7 +74,7 @@ def test_load_from_old_style_env(monkeypatch, default_config): # Test loading configuration from old-style environment variables using monkeypatch monkeypatch.setenv('LLM_API_KEY', 'test-api-key') monkeypatch.setenv('AGENT_MEMORY_ENABLED', 'True') - monkeypatch.setenv('DEFAULT_AGENT', 'PlannerAgent') + monkeypatch.setenv('DEFAULT_AGENT', 'BrowsingAgent') monkeypatch.setenv('WORKSPACE_BASE', '/opt/files/workspace') monkeypatch.setenv('SANDBOX_BASE_CONTAINER_IMAGE', 'custom_image') @@ -79,7 +82,7 @@ def test_load_from_old_style_env(monkeypatch, default_config): assert default_config.get_llm_config().api_key == 'test-api-key' assert default_config.get_agent_config().memory_enabled is True - assert default_config.default_agent == 'PlannerAgent' + assert default_config.default_agent == 'BrowsingAgent' assert default_config.workspace_base == '/opt/files/workspace' assert default_config.workspace_mount_path is None # before finalize_config assert default_config.workspace_mount_path_in_sandbox is not None @@ -154,6 +157,60 @@ def test_load_from_new_style_toml(default_config, temp_toml_file): assert default_config.workspace_mount_path == '/opt/files2/workspace' +def test_llm_config_native_tool_calling(default_config, temp_toml_file, monkeypatch): + # default is None + assert default_config.get_llm_config().native_tool_calling is None + + # without `[core]` section, native_tool_calling is not set because the file is not loaded + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write( + """ +[llm.gpt4o-mini] +native_tool_calling = true +""" + ) + + load_from_toml(default_config, temp_toml_file) + assert default_config.get_llm_config().native_tool_calling is None + assert default_config.get_llm_config('gpt4o-mini').native_tool_calling is None + + # set to false + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write( + """ +[core] + +[llm.gpt4o-mini] +native_tool_calling = false +""" + ) + load_from_toml(default_config, temp_toml_file) + assert default_config.get_llm_config().native_tool_calling is None + assert default_config.get_llm_config('gpt4o-mini').native_tool_calling is False + + # set to true using string + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write( + """ +[core] + +[llm.gpt4o-mini] +native_tool_calling = true +""" + ) + load_from_toml(default_config, temp_toml_file) + assert default_config.get_llm_config('gpt4o-mini').native_tool_calling is True + + # override to false by env + # see utils.set_attr_from_env + monkeypatch.setenv('LLM_NATIVE_TOOL_CALLING', 'false') + load_from_env(default_config, os.environ) + assert default_config.get_llm_config().native_tool_calling is False + assert ( + default_config.get_llm_config('gpt4o-mini').native_tool_calling is True + ) # load_from_env didn't override the named config set in the toml file under [llm.gpt4o-mini] + + def test_compat_load_sandbox_from_toml(default_config: AppConfig, temp_toml_file: str): # test loading configuration from a new-style TOML file # uses a toml file with sandbox_vars instead of a sandbox section @@ -323,6 +380,42 @@ def test_sandbox_config_from_toml(monkeypatch, default_config, temp_toml_file): assert default_config.sandbox.user_id == 1001 +def test_security_config_from_toml(default_config, temp_toml_file): + """Test loading security specific configurations.""" + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write( + """ +[core] # make sure core is loaded first +workspace_base = "/opt/files/workspace" + +[llm] +model = "test-model" + +[security] +confirmation_mode = false +security_analyzer = "semgrep" +""" + ) + + load_from_toml(default_config, temp_toml_file) + assert default_config.security.confirmation_mode is False + assert default_config.security.security_analyzer == 'semgrep' + + +def test_security_config_from_dict(): + """Test creating SecurityConfig instance from dictionary.""" + from openhands.core.config.security_config import SecurityConfig + + # Test with all fields + config_dict = {'confirmation_mode': True, 'security_analyzer': 'some_analyzer'} + + security_config = SecurityConfig.from_dict(config_dict) + + # Verify all fields are correctly set + assert security_config.confirmation_mode is True + assert security_config.security_analyzer == 'some_analyzer' + + def test_defaults_dict_after_updates(default_config): # Test that `defaults_dict` retains initial values after updates. initial_defaults = default_config.defaults_dict @@ -333,8 +426,10 @@ def test_defaults_dict_after_updates(default_config): updated_config.get_llm_config().api_key = 'updated-api-key' updated_config.get_llm_config('llm').api_key = 'updated-api-key' updated_config.get_llm_config_from_agent('agent').api_key = 'updated-api-key' - updated_config.get_llm_config_from_agent('PlannerAgent').api_key = 'updated-api-key' - updated_config.default_agent = 'PlannerAgent' + updated_config.get_llm_config_from_agent( + 'BrowsingAgent' + ).api_key = 'updated-api-key' + updated_config.default_agent = 'BrowsingAgent' defaults_after_updates = updated_config.defaults_dict assert defaults_after_updates['default_agent']['default'] == 'CodeActAgent' @@ -352,10 +447,11 @@ def test_invalid_toml_format(monkeypatch, temp_toml_file, default_config): monkeypatch.setenv('LLM_MODEL', 'gpt-5-turbo-1106') monkeypatch.setenv('WORKSPACE_MOUNT_PATH', '/home/user/project') monkeypatch.delenv('LLM_API_KEY', raising=False) + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: toml_file.write('INVALID TOML CONTENT') - load_from_toml(default_config) + load_from_toml(default_config, temp_toml_file) load_from_env(default_config, os.environ) default_config.jwt_secret = None # prevent leak for llm in default_config.llms.values(): @@ -365,6 +461,122 @@ def test_invalid_toml_format(monkeypatch, temp_toml_file, default_config): assert default_config.workspace_mount_path == '/home/user/project' +def test_load_from_toml_file_not_found(default_config): + """Test loading configuration when the TOML file doesn't exist. + + This ensures that: + 1. The program doesn't crash when the config file is missing + 2. The config object retains its default values + 3. The application remains usable + """ + # Try to load from a non-existent file + load_from_toml(default_config, 'nonexistent.toml') + + # Verify that config object maintains default values + assert default_config.get_llm_config() is not None + assert default_config.get_agent_config() is not None + assert default_config.sandbox is not None + + +def test_core_not_in_toml(default_config, temp_toml_file): + """Test loading configuration when the core section is not in the TOML file. + + default values should be used for the missing sections. + """ + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write(""" +[llm] +model = "test-model" + +[agent] +memory_enabled = true + +[sandbox] +timeout = 1 +base_container_image = "custom_image" +user_id = 1001 + +[security] +security_analyzer = "semgrep" +""") + + load_from_toml(default_config, temp_toml_file) + assert default_config.get_llm_config().model == 'claude-3-5-sonnet-20241022' + assert default_config.get_agent_config().memory_enabled is False + assert ( + default_config.sandbox.base_container_image + == 'nikolaik/python-nodejs:python3.12-nodejs22' + ) + # assert default_config.sandbox.user_id == 1007 + assert default_config.security.security_analyzer is None + + +def test_load_from_toml_partial_invalid(default_config, temp_toml_file, caplog): + """Test loading configuration with partially invalid TOML content. + + This ensures that: + 1. Valid configuration sections are properly loaded + 2. Invalid fields are ignored gracefully + 3. The config object maintains correct values for valid fields + 4. Appropriate warnings are logged for invalid fields + + See `openhands/core/schema/config.py` for the list of valid fields. + """ + with open(temp_toml_file, 'w', encoding='utf-8') as f: + f.write(""" +[core] +debug = true + +[llm] +# No set in `openhands/core/schema/config.py` +invalid_field = "test" +model = "gpt-4" + +[agent] +memory_enabled = true + +[sandbox] +invalid_field_in_sandbox = "test" +""") + + # Create a string buffer to capture log output + # Referenced from test_logging.py and `mock_logger` + log_output = StringIO() + handler = logging.StreamHandler(log_output) + handler.setLevel(logging.WARNING) + formatter = logging.Formatter('%(message)s') + handler.setFormatter(formatter) + openhands_logger.addHandler(handler) + + try: + load_from_toml(default_config, temp_toml_file) + log_content = log_output.getvalue() + + # invalid [llm] config + # Verify that the appropriate warning was logged + assert 'Cannot parse [llm] config from toml' in log_content + assert 'values have not been applied' in log_content + # Error: LLMConfig.__init__() got an unexpected keyword argume + assert ( + 'Error: LLMConfig.__init__() got an unexpected keyword argume' + in log_content + ) + assert 'invalid_field' in log_content + + # invalid [sandbox] config + assert 'Cannot parse [sandbox] config from toml' in log_content + assert 'values have not been applied' in log_content + assert 'invalid_field_in_sandbox' in log_content + + # Verify valid configurations are loaded. Load from default instead of `config.toml` + # assert default_config.debug is True + assert default_config.debug is False + assert default_config.get_llm_config().model == 'claude-3-5-sonnet-20241022' + assert default_config.get_agent_config().memory_enabled is True + finally: + openhands_logger.removeHandler(handler) + + def test_finalize_config(default_config): # Test finalize config assert default_config.workspace_mount_path is None @@ -547,7 +759,7 @@ def test_get_agent_configs(default_config, temp_toml_file): [agent.CodeActAgent] memory_enabled = true -[agent.PlannerAgent] +[agent.BrowsingAgent] memory_max_threads = 10 """ @@ -558,5 +770,5 @@ def test_get_agent_configs(default_config, temp_toml_file): codeact_config = default_config.get_agent_configs().get('CodeActAgent') assert codeact_config.memory_enabled is True - planner_config = default_config.get_agent_configs().get('PlannerAgent') - assert planner_config.memory_max_threads == 10 + browsing_config = default_config.get_agent_configs().get('BrowsingAgent') + assert browsing_config.memory_max_threads == 10 diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py new file mode 100644 index 000000000000..709cb4cae65f --- /dev/null +++ b/tests/unit/test_conversation.py @@ -0,0 +1,112 @@ +import json +from contextlib import contextmanager +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from openhands.server.routes.manage_conversations import ( + get_conversation, + search_conversations, + update_conversation, +) +from openhands.storage.data_models.conversation_info import ConversationInfo +from openhands.storage.data_models.conversation_info_result_set import ( + ConversationInfoResultSet, +) +from openhands.storage.data_models.conversation_status import ConversationStatus +from openhands.storage.memory import InMemoryFileStore + + +@contextmanager +def _patch_store(): + file_store = InMemoryFileStore() + file_store.write( + 'sessions/some_conversation_id/metadata.json', + json.dumps( + { + 'title': 'Some Conversation', + 'selected_repository': 'foobar', + 'conversation_id': 'some_conversation_id', + 'github_user_id': 'github_user', + 'last_updated_at': '2025-01-01T00:00:00', + } + ), + ) + with patch( + 'openhands.storage.conversation.file_conversation_store.get_file_store', + MagicMock(return_value=file_store), + ): + with patch( + 'openhands.server.routes.manage_conversations.session_manager.file_store', + file_store, + ): + yield + + +@pytest.mark.asyncio +async def test_search_conversations(): + with _patch_store(): + result_set = await search_conversations( + MagicMock(state=MagicMock(github_token='')) + ) + expected = ConversationInfoResultSet( + results=[ + ConversationInfo( + conversation_id='some_conversation_id', + title='Some Conversation', + last_updated_at=datetime.fromisoformat('2025-01-01T00:00:00'), + status=ConversationStatus.STOPPED, + selected_repository='foobar', + ) + ] + ) + assert result_set == expected + + +@pytest.mark.asyncio +async def test_get_conversation(): + with _patch_store(): + conversation = await get_conversation( + 'some_conversation_id', MagicMock(state=MagicMock(github_token='')) + ) + expected = ConversationInfo( + conversation_id='some_conversation_id', + title='Some Conversation', + last_updated_at=datetime.fromisoformat('2025-01-01T00:00:00'), + status=ConversationStatus.STOPPED, + selected_repository='foobar', + ) + assert conversation == expected + + +@pytest.mark.asyncio +async def test_get_missing_conversation(): + with _patch_store(): + assert ( + await get_conversation( + 'no_such_conversation', MagicMock(state=MagicMock(github_token='')) + ) + is None + ) + + +@pytest.mark.asyncio +async def test_update_conversation(): + with _patch_store(): + await update_conversation( + MagicMock(state=MagicMock(github_token='')), + 'some_conversation_id', + 'New Title', + ) + conversation = await get_conversation( + 'some_conversation_id', MagicMock(state=MagicMock(github_token='')) + ) + expected = ConversationInfo( + conversation_id='some_conversation_id', + title='New Title', + last_updated_at=datetime.fromisoformat('2025-01-01T00:00:00'), + status=ConversationStatus.STOPPED, + selected_repository='foobar', + ) + assert conversation == expected diff --git a/tests/unit/test_micro_agents.py b/tests/unit/test_micro_agents.py index 5910582e4ec7..c7461bbda226 100644 --- a/tests/unit/test_micro_agents.py +++ b/tests/unit/test_micro_agents.py @@ -31,7 +31,7 @@ def event_stream(temp_dir): def agent_configs(): return { 'CoderAgent': AgentConfig(memory_enabled=True), - 'PlannerAgent': AgentConfig(memory_enabled=True), + 'BrowsingAgent': AgentConfig(memory_enabled=True), } diff --git a/tests/unit/test_microagent_utils.py b/tests/unit/test_microagent_utils.py index 745e6532550e..dca121909f84 100644 --- a/tests/unit/test_microagent_utils.py +++ b/tests/unit/test_microagent_utils.py @@ -1,31 +1,145 @@ -import os +"""Tests for the microagent system.""" -from pytest import MonkeyPatch +import tempfile +from pathlib import Path -import openhands.agenthub # noqa: F401 -from openhands.utils.microagent import MicroAgent +import pytest + +from openhands.core.exceptions import MicroAgentValidationError +from openhands.microagent import ( + BaseMicroAgent, + KnowledgeMicroAgent, + MicroAgentMetadata, + MicroAgentType, + RepoMicroAgent, + TaskMicroAgent, + load_microagents_from_dir, +) CONTENT = ( '# dummy header\n' 'dummy content\n' '## dummy subheader\n' 'dummy subcontent\n' ) -def test_micro_agent_load(tmp_path, monkeypatch: MonkeyPatch): - with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f: - f.write( - ( - '---\n' - 'name: dummy\n' - 'agent: CodeActAgent\n' - 'require_env_var:\n' - ' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n' - '---\n' + CONTENT - ) - ) - - # Patch the required environment variable - monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value') - - micro_agent = MicroAgent(os.path.join(tmp_path, 'dummy.md')) - assert micro_agent is not None - assert micro_agent.content == CONTENT.strip() +def test_legacy_micro_agent_load(tmp_path): + """Test loading of legacy microagents.""" + legacy_file = tmp_path / '.openhands_instructions' + legacy_file.write_text(CONTENT) + + micro_agent = BaseMicroAgent.load(legacy_file) + assert isinstance(micro_agent, RepoMicroAgent) + assert micro_agent.name == 'repo_legacy' + assert micro_agent.content == CONTENT + assert micro_agent.type == MicroAgentType.REPO_KNOWLEDGE + + +@pytest.fixture +def temp_microagents_dir(): + """Create a temporary directory with test microagents.""" + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + + # Create test knowledge agent + knowledge_agent = """--- +name: test_knowledge_agent +type: knowledge +version: 1.0.0 +agent: CodeActAgent +triggers: + - test + - pytest +--- + +# Test Guidelines + +Testing best practices and guidelines. +""" + (root / 'knowledge.md').write_text(knowledge_agent) + + # Create test repo agent + repo_agent = """--- +name: test_repo_agent +type: repo +version: 1.0.0 +agent: CodeActAgent +--- + +# Test Repository Agent + +Repository-specific test instructions. +""" + (root / 'repo.md').write_text(repo_agent) + + # Create test task agent + task_agent = """--- +name: test_task +type: task +version: 1.0.0 +agent: CodeActAgent +--- + +# Test Task + +Test task content +""" + (root / 'task.md').write_text(task_agent) + + yield root + + +def test_knowledge_agent(): + """Test knowledge agent functionality.""" + agent = KnowledgeMicroAgent( + name='test', + content='Test content', + metadata=MicroAgentMetadata( + name='test', type=MicroAgentType.KNOWLEDGE, triggers=['test', 'pytest'] + ), + source='test.md', + type=MicroAgentType.KNOWLEDGE, + ) + + assert agent.match_trigger('running a test') == 'test' + assert agent.match_trigger('using pytest') == 'test' + assert agent.match_trigger('no match here') is None + assert agent.triggers == ['test', 'pytest'] + + +def test_load_microagents(temp_microagents_dir): + """Test loading microagents from directory.""" + repo_agents, knowledge_agents, task_agents = load_microagents_from_dir( + temp_microagents_dir + ) + + # Check knowledge agents + assert len(knowledge_agents) == 1 + agent = knowledge_agents['test_knowledge_agent'] + assert isinstance(agent, KnowledgeMicroAgent) + assert 'test' in agent.triggers + + # Check repo agents + assert len(repo_agents) == 1 + agent = repo_agents['test_repo_agent'] + assert isinstance(agent, RepoMicroAgent) + + # Check task agents + assert len(task_agents) == 1 + agent = task_agents['test_task'] + assert isinstance(agent, TaskMicroAgent) + + +def test_invalid_agent_type(temp_microagents_dir): + """Test loading agent with invalid type.""" + invalid_agent = """--- +name: test_invalid +type: invalid +version: 1.0.0 +agent: CodeActAgent +--- + +Invalid agent content +""" + (temp_microagents_dir / 'invalid.md').write_text(invalid_agent) + + with pytest.raises(MicroAgentValidationError): + BaseMicroAgent.load(temp_microagents_dir / 'invalid.md') diff --git a/tests/unit/test_prompt_caching.py b/tests/unit/test_prompt_caching.py index 1ce12a00a6d3..7e577c2bca5e 100644 --- a/tests/unit/test_prompt_caching.py +++ b/tests/unit/test_prompt_caching.py @@ -24,7 +24,8 @@ def mock_llm(): @pytest.fixture def codeact_agent(mock_llm): config = AgentConfig() - return CodeActAgent(mock_llm, config) + agent = CodeActAgent(mock_llm, config) + return agent def response_mock(content: str, tool_call_id: str): diff --git a/tests/unit/test_prompt_manager.py b/tests/unit/test_prompt_manager.py index ce34a9888c2a..6d5cf3a983c7 100644 --- a/tests/unit/test_prompt_manager.py +++ b/tests/unit/test_prompt_manager.py @@ -4,7 +4,7 @@ import pytest from openhands.core.message import Message, TextContent -from openhands.utils.microagent import MicroAgent +from openhands.microagent import BaseMicroAgent from openhands.utils.prompt import PromptManager @@ -24,6 +24,7 @@ def test_prompt_manager_with_microagent(prompt_dir): microagent_content = """ --- name: flarglebargle +type: knowledge agent: CodeActAgent triggers: - flarglebargle @@ -44,7 +45,8 @@ def test_prompt_manager_with_microagent(prompt_dir): ) assert manager.prompt_dir == prompt_dir - assert len(manager.microagents) == 1 + assert len(manager.repo_microagents) == 0 + assert len(manager.knowledge_microagents) == 1 assert isinstance(manager.get_system_message(), str) assert ( @@ -66,7 +68,9 @@ def test_prompt_manager_with_microagent(prompt_dir): def test_prompt_manager_file_not_found(prompt_dir): with pytest.raises(FileNotFoundError): - MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md')) + BaseMicroAgent.load( + os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md') + ) def test_prompt_manager_template_rendering(prompt_dir): @@ -93,6 +97,7 @@ def test_prompt_manager_disabled_microagents(prompt_dir): microagent1_content = """ --- name: Test Microagent 1 +type: knowledge agent: CodeActAgent triggers: - test1 @@ -103,6 +108,7 @@ def test_prompt_manager_disabled_microagents(prompt_dir): microagent2_content = """ --- name: Test Microagent 2 +type: knowledge agent: CodeActAgent triggers: - test2 @@ -125,9 +131,9 @@ def test_prompt_manager_disabled_microagents(prompt_dir): disabled_microagents=['Test Microagent 1'], ) - assert len(manager.microagents) == 1 - assert 'Test Microagent 2' in manager.microagents - assert 'Test Microagent 1' not in manager.microagents + assert len(manager.knowledge_microagents) == 1 + assert 'Test Microagent 2' in manager.knowledge_microagents + assert 'Test Microagent 1' not in manager.knowledge_microagents # Test that all microagents are enabled by default manager = PromptManager( @@ -135,9 +141,9 @@ def test_prompt_manager_disabled_microagents(prompt_dir): microagent_dir=os.path.join(prompt_dir, 'micro'), ) - assert len(manager.microagents) == 2 - assert 'Test Microagent 1' in manager.microagents - assert 'Test Microagent 2' in manager.microagents + assert len(manager.knowledge_microagents) == 2 + assert 'Test Microagent 1' in manager.knowledge_microagents + assert 'Test Microagent 2' in manager.knowledge_microagents # Clean up temporary files os.remove(os.path.join(prompt_dir, 'micro', f'{microagent1_name}.md')) diff --git a/tests/unit/test_response_parsing.py b/tests/unit/test_response_parsing.py index 02710f48987f..fd588d4c6edf 100644 --- a/tests/unit/test_response_parsing.py +++ b/tests/unit/test_response_parsing.py @@ -1,9 +1,6 @@ import pytest from openhands.agenthub.micro.agent import parse_response as parse_response_micro -from openhands.agenthub.planner_agent.prompt import ( - parse_response as parse_response_planner, -) from openhands.core.exceptions import LLMResponseError from openhands.core.utils.json import loads as custom_loads from openhands.events.action import ( @@ -14,7 +11,7 @@ @pytest.mark.parametrize( 'parse_response_module', - [parse_response_micro, parse_response_planner], + [parse_response_micro], ) def test_parse_single_complete_json(parse_response_module): input_response = """ @@ -34,7 +31,7 @@ def test_parse_single_complete_json(parse_response_module): @pytest.mark.parametrize( 'parse_response_module', - [parse_response_micro, parse_response_planner], + [parse_response_micro], ) def test_parse_json_with_surrounding_text(parse_response_module): input_response = """ @@ -57,7 +54,7 @@ def test_parse_json_with_surrounding_text(parse_response_module): @pytest.mark.parametrize( 'parse_response_module', - [parse_response_micro, parse_response_planner], + [parse_response_micro], ) def test_parse_first_of_multiple_jsons(parse_response_module): input_response = """ diff --git a/tests/unit/test_search_utils.py b/tests/unit/test_search_utils.py new file mode 100644 index 000000000000..3e68dbfab79f --- /dev/null +++ b/tests/unit/test_search_utils.py @@ -0,0 +1,24 @@ +from openhands.utils.search_utils import offset_to_page_id, page_id_to_offset + + +def test_offset_to_page_id(): + # Test with has_next=True + assert bool(offset_to_page_id(10, True)) + assert bool(offset_to_page_id(0, True)) + + # Test with has_next=False should return None + assert offset_to_page_id(10, False) is None + assert offset_to_page_id(0, False) is None + + +def test_page_id_to_offset(): + # Test with None should return 0 + assert page_id_to_offset(None) == 0 + + +def test_bidirectional_conversion(): + # Test converting offset to page_id and back + test_offsets = [0, 1, 10, 100, 1000] + for offset in test_offsets: + page_id = offset_to_page_id(offset, True) + assert page_id_to_offset(page_id) == offset