diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml index 028316ee05d5..a9d90c38b139 100644 --- a/.github/workflows/openhands-resolver.yml +++ b/.github/workflows/openhands-resolver.yml @@ -184,6 +184,7 @@ jobs: }); - name: Install OpenHands + id: install_openhands uses: actions/github-script@v7 env: COMMENT_BODY: ${{ github.event.comment.body || '' }} @@ -196,7 +197,6 @@ jobs: const reviewBody = process.env.REVIEW_BODY.trim(); const labelName = process.env.LABEL_NAME.trim(); const eventName = process.env.EVENT_NAME.trim(); - // Check conditions const isExperimentalLabel = labelName === "fix-me-experimental"; const isIssueCommentExperimental = @@ -205,6 +205,9 @@ jobs: const isReviewCommentExperimental = eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp"); + // Set output variable + core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental); + // Perform package installation if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) { console.log("Installing experimental OpenHands..."); @@ -230,7 +233,8 @@ jobs: --issue-number ${{ env.ISSUE_NUMBER }} \ --issue-type ${{ env.ISSUE_TYPE }} \ --max-iterations ${{ env.MAX_ITERATIONS }} \ - --comment-id ${{ env.COMMENT_ID }} + --comment-id ${{ env.COMMENT_ID }} \ + --is-experimental ${{ steps.install_openhands.outputs.isExperimental }} - name: Check resolution result id: check_result diff --git a/compose.yml b/docker-compose.yml similarity index 65% rename from compose.yml rename to docker-compose.yml index dc36f0d43bce..f5b3b55583bc 100644 --- a/compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# + services: openhands: build: @@ -7,8 +7,8 @@ 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.19-nikolaik} - - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik} + #- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: - "3000:3000" @@ -16,6 +16,7 @@ services: - "host.docker.internal:host-gateway" volumes: - /var/run/docker.sock:/var/run/docker.sock + - ~/.openhands-state:/.openhands-state - ${WORKSPACE_BASE:-$PWD/workspace}:/opt/workspace_base pull_policy: build stdin_open: true diff --git a/evaluation/benchmarks/the_agent_company/run_infer.py b/evaluation/benchmarks/the_agent_company/run_infer.py index 03561913087c..6f0cda2efe40 100644 --- a/evaluation/benchmarks/the_agent_company/run_infer.py +++ b/evaluation/benchmarks/the_agent_company/run_infer.py @@ -80,7 +80,7 @@ def load_dependencies(runtime: Runtime) -> List[str]: def init_task_env(runtime: Runtime, hostname: str, env_llm_config: LLMConfig): command = ( f'SERVER_HOSTNAME={hostname} ' - f'LITELLM_API_KEY={env_llm_config.api_key} ' + f'LITELLM_API_KEY={env_llm_config.api_key.get_secret_value() if env_llm_config.api_key else None} ' f'LITELLM_BASE_URL={env_llm_config.base_url} ' f'LITELLM_MODEL={env_llm_config.model} ' 'bash /utils/init.sh' @@ -165,7 +165,7 @@ def run_evaluator( runtime: Runtime, env_llm_config: LLMConfig, trajectory_path: str, result_path: str ): command = ( - f'LITELLM_API_KEY={env_llm_config.api_key} ' + f'LITELLM_API_KEY={env_llm_config.api_key.get_secret_value() if env_llm_config.api_key else None} ' f'LITELLM_BASE_URL={env_llm_config.base_url} ' f'LITELLM_MODEL={env_llm_config.model} ' f"DECRYPTION_KEY='theagentcompany is all you need' " # Hardcoded Key diff --git a/evaluation/utils/shared.py b/evaluation/utils/shared.py index 23c5b319d3c2..86de1a01d2cd 100644 --- a/evaluation/utils/shared.py +++ b/evaluation/utils/shared.py @@ -52,30 +52,6 @@ class EvalMetadata(BaseModel): details: dict[str, Any] | None = None condenser_config: CondenserConfig | None = None - def model_dump(self, *args, **kwargs): - dumped_dict = super().model_dump(*args, **kwargs) - # avoid leaking sensitive information - dumped_dict['llm_config'] = self.llm_config.to_safe_dict() - if hasattr(self.condenser_config, 'llm_config'): - dumped_dict['condenser_config']['llm_config'] = ( - self.condenser_config.llm_config.to_safe_dict() - ) - - return dumped_dict - - def model_dump_json(self, *args, **kwargs): - dumped = super().model_dump_json(*args, **kwargs) - dumped_dict = json.loads(dumped) - # avoid leaking sensitive information - dumped_dict['llm_config'] = self.llm_config.to_safe_dict() - if hasattr(self.condenser_config, 'llm_config'): - dumped_dict['condenser_config']['llm_config'] = ( - self.condenser_config.llm_config.to_safe_dict() - ) - - logger.debug(f'Dumped metadata: {dumped_dict}') - return json.dumps(dumped_dict) - class EvalOutput(BaseModel): # NOTE: User-specified @@ -98,23 +74,6 @@ class EvalOutput(BaseModel): # Optionally save the input test instance instance: dict[str, Any] | None = None - def model_dump(self, *args, **kwargs): - dumped_dict = super().model_dump(*args, **kwargs) - # Remove None values - dumped_dict = {k: v for k, v in dumped_dict.items() if v is not None} - # Apply custom serialization for metadata (to avoid leaking sensitive information) - if self.metadata is not None: - dumped_dict['metadata'] = self.metadata.model_dump() - return dumped_dict - - def model_dump_json(self, *args, **kwargs): - dumped = super().model_dump_json(*args, **kwargs) - dumped_dict = json.loads(dumped) - # Apply custom serialization for metadata (to avoid leaking sensitive information) - if 'metadata' in dumped_dict: - dumped_dict['metadata'] = json.loads(self.metadata.model_dump_json()) - return json.dumps(dumped_dict) - class EvalException(Exception): pass @@ -314,7 +273,7 @@ def update_progress( logger.info( f'Finished evaluation for instance {result.instance_id}: {str(result.test_result)[:300]}...\n' ) - output_fp.write(json.dumps(result.model_dump()) + '\n') + output_fp.write(result.model_dump_json() + '\n') output_fp.flush() diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index 1e80607301b0..9d96e7a49bdc 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -1,10 +1,12 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; import { Sidebar } from "#/components/features/sidebar/sidebar"; import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; +import OpenHands from "#/api/open-hands"; +import { MOCK_USER_PREFERENCES } from "#/mocks/handlers"; const renderSidebar = () => { const RouterStub = createRoutesStub([ @@ -43,4 +45,101 @@ describe("Sidebar", () => { ).not.toBeInTheDocument(); }, ); + + describe("Settings", () => { + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch settings data on mount", () => { + renderSidebar(); + expect(getSettingsSpy).toHaveBeenCalledOnce(); + }); + + it("should send all settings data when saving AI configuration", async () => { + const user = userEvent.setup(); + renderSidebar(); + + const settingsButton = screen.getByTestId("settings-button"); + await user.click(settingsButton); + + const settingsModal = screen.getByTestId("ai-config-modal"); + const saveButton = within(settingsModal).getByTestId( + "save-settings-button", + ); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith({ + ...MOCK_USER_PREFERENCES.settings, + // the actual values are falsey (null or "") but we're checking for undefined + llm_api_key: undefined, + llm_base_url: undefined, + security_analyzer: undefined, + }); + }); + + it("should send all settings data when saving account settings", async () => { + const user = userEvent.setup(); + renderSidebar(); + + const userAvatar = screen.getByTestId("user-avatar"); + await user.click(userAvatar); + + const menu = screen.getByTestId("account-settings-context-menu"); + const accountSettingsButton = within(menu).getByTestId( + "account-settings-button", + ); + await user.click(accountSettingsButton); + + const accountSettingsModal = screen.getByTestId("account-settings-form"); + const saveButton = + within(accountSettingsModal).getByTestId("save-settings"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith({ + ...MOCK_USER_PREFERENCES.settings, + llm_api_key: undefined, // null or undefined + }); + }); + + it("should not reset AI configuration when saving account settings", async () => { + const user = userEvent.setup(); + renderSidebar(); + + const userAvatar = screen.getByTestId("user-avatar"); + await user.click(userAvatar); + + const menu = screen.getByTestId("account-settings-context-menu"); + const accountSettingsButton = within(menu).getByTestId( + "account-settings-button", + ); + await user.click(accountSettingsButton); + + const accountSettingsModal = screen.getByTestId("account-settings-form"); + + const languageInput = + within(accountSettingsModal).getByLabelText(/language/i); + await user.click(languageInput); + + const norskOption = screen.getByText(/norsk/i); + await user.click(norskOption); + + const tokenInput = + within(accountSettingsModal).getByLabelText(/github token/i); + await user.type(tokenInput, "new-token"); + + const saveButton = + within(accountSettingsModal).getByTestId("save-settings"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith({ + ...MOCK_USER_PREFERENCES.settings, + language: "no", + llm_api_key: undefined, // null or undefined + }); + }); + }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 628442ae5fa4..ba7d8ed31059 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "i18next": "^24.2.1", "i18next-browser-languagedetector": "^8.0.2", "i18next-http-backend": "^3.0.1", - "isbot": "^5.1.20", + "isbot": "^5.1.21", "jose": "^5.9.4", "monaco-editor": "^0.52.2", "posthog-js": "^1.205.0", @@ -38,7 +38,7 @@ "react-redux": "^9.2.0", "react-router": "^7.1.1", "react-syntax-highlighter": "^15.6.1", - "react-textarea-autosize": "^8.5.4", + "react-textarea-autosize": "^8.5.7", "remark-gfm": "^4.0.0", "sirv-cli": "^3.0.0", "socket.io-client": "^4.8.1", @@ -57,7 +57,7 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^22.10.5", - "@types/react": "^19.0.3", + "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", @@ -77,13 +77,13 @@ "eslint-plugin-react": "^7.37.3", "eslint-plugin-react-hooks": "^4.6.2", "husky": "^9.1.6", - "jsdom": "^25.0.1", + "jsdom": "^26.0.0", "lint-staged": "^15.3.0", "msw": "^2.6.6", "postcss": "^8.4.47", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", - "typescript": "^5.7.2", + "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^1.6.0" @@ -124,6 +124,28 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.2.tgz", + "integrity": "sha512-RtWv9jFN2/bLExuZgFFZ0I3pWWeezAHGgrmjqGGWclATl1aDe3yhCUaI0Ilkp6OCk9zX7+FjvDasEX8Q9Rxc5w==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.1", + "@csstools/css-color-parser": "^3.0.7", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^11.0.2" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -691,6 +713,116 @@ "node": ">= 4.0.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.1.tgz", + "integrity": "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.1.tgz", + "integrity": "sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz", + "integrity": "sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.0.1", + "@csstools/css-calc": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -5447,9 +5579,9 @@ } }, "node_modules/@types/react": { - "version": "19.0.3", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz", - "integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==", + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.4.tgz", + "integrity": "sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==", "dependencies": { "csstype": "^3.0.2" } @@ -7448,13 +7580,13 @@ } }, "node_modules/cssstyle": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", - "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz", + "integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==", "dev": true, - "license": "MIT", "dependencies": { - "rrweb-cssom": "^0.7.1" + "@asamuzakjp/css-color": "^2.8.2", + "rrweb-cssom": "^0.8.0" }, "engines": { "node": ">=18" @@ -10670,9 +10802,9 @@ "license": "MIT" }, "node_modules/isbot": { - "version": "5.1.20", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.20.tgz", - "integrity": "sha512-cW535S5c05UBfx8bTAZHACjEXyY/p10bvAx5YeqoLEFoGC1HQ6A5n3ScpZRYd1zSwwNF8yYkEOq2F7WjFhX2ig==", + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.21.tgz", + "integrity": "sha512-0q3naRVpENL0ReKHeNcwn/G7BDynp0DqZUckKyFtM9+hmpnPqgm8+8wbjiVZ0XNhq1wPQV28/Pb8Snh5adeUHA==", "engines": { "node": ">=18" } @@ -10808,23 +10940,22 @@ } }, "node_modules/jsdom": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", - "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz", + "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==", "dev": true, - "license": "MIT", "dependencies": { - "cssstyle": "^4.1.0", + "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", - "form-data": "^4.0.0", + "form-data": "^4.0.1", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.0.0", @@ -10832,7 +10963,7 @@ "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", + "whatwg-url": "^14.1.0", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, @@ -10840,7 +10971,7 @@ "node": ">=18" }, "peerDependencies": { - "canvas": "^2.11.2" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -14103,10 +14234,9 @@ } }, "node_modules/react-textarea-autosize": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz", - "integrity": "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==", - "license": "MIT", + "version": "8.5.7", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.7.tgz", + "integrity": "sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==", "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", @@ -14639,11 +14769,10 @@ } }, "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT" + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -16313,11 +16442,10 @@ } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "devOptional": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index 61404acb76e4..5d57ccfde2e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,7 @@ "i18next": "^24.2.1", "i18next-browser-languagedetector": "^8.0.2", "i18next-http-backend": "^3.0.1", - "isbot": "^5.1.20", + "isbot": "^5.1.21", "jose": "^5.9.4", "monaco-editor": "^0.52.2", "posthog-js": "^1.205.0", @@ -37,7 +37,7 @@ "react-redux": "^9.2.0", "react-router": "^7.1.1", "react-syntax-highlighter": "^15.6.1", - "react-textarea-autosize": "^8.5.4", + "react-textarea-autosize": "^8.5.7", "remark-gfm": "^4.0.0", "sirv-cli": "^3.0.0", "socket.io-client": "^4.8.1", @@ -84,7 +84,7 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^22.10.5", - "@types/react": "^19.0.3", + "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", @@ -104,13 +104,13 @@ "eslint-plugin-react": "^7.37.3", "eslint-plugin-react-hooks": "^4.6.2", "husky": "^9.1.6", - "jsdom": "^25.0.1", + "jsdom": "^26.0.0", "lint-staged": "^15.3.0", "msw": "^2.6.6", "postcss": "^8.4.47", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", - "typescript": "^5.7.2", + "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^1.6.0" diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx index bf695cbc6ba7..92a3359e83c9 100644 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx @@ -27,7 +27,10 @@ export function AccountSettingsContextMenu({ ref={ref} className="absolute left-full -top-1 z-10" > - + {t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)} diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index f6a1728ce5ed..cee990f9e963 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -12,7 +12,7 @@ import { SettingsButton } from "#/components/shared/buttons/settings-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; import { SettingsModal } from "#/components/shared/modals/settings/settings-modal"; -import { useSettingsUpToDate } from "#/context/settings-up-to-date-context"; +import { useCurrentSettings } from "#/context/settings-context"; import { useSettings } from "#/hooks/query/use-settings"; import { ConversationPanel } from "../conversation-panel/conversation-panel"; import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; @@ -28,8 +28,13 @@ export function Sidebar() { const user = useGitHubUser(); const { data: isAuthed } = useIsAuthed(); const { logout } = useAuth(); - const { data: settings, isError: settingsIsError } = useSettings(); - const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate(); + const { + data: settings, + isError: settingsIsError, + isSuccess: settingsSuccessfulyFetched, + } = useSettings(); + + const { isUpToDate: settingsAreUpToDate } = useCurrentSettings(); const [accountSettingsModalOpen, setAccountSettingsModalOpen] = React.useState(false); @@ -106,7 +111,7 @@ export function Sidebar() { )} {settingsIsError || - (showSettingsModal && ( + (showSettingsModal && settingsSuccessfulyFetched && ( setSettingsModalIsOpen(false)} diff --git a/frontend/src/components/layout/served-app-label.tsx b/frontend/src/components/layout/served-app-label.tsx index 824b3f3608f7..47687908905d 100644 --- a/frontend/src/components/layout/served-app-label.tsx +++ b/frontend/src/components/layout/served-app-label.tsx @@ -5,7 +5,10 @@ export function ServedAppLabel() { return (
-
App
+
+
App
+ BETA +
{activeHost &&
}
); diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx index fb162103eb5f..6e1363274944 100644 --- a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx +++ b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx @@ -13,7 +13,7 @@ import { ModalButton } from "../../buttons/modal-button"; import { CustomInput } from "../../custom-input"; import { FormFieldset } from "../../form-fieldset"; import { useConfig } from "#/hooks/query/use-config"; -import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useCurrentSettings } from "#/context/settings-context"; interface AccountSettingsFormProps { onClose: () => void; @@ -30,10 +30,10 @@ export function AccountSettingsForm({ }: AccountSettingsFormProps) { const { gitHubToken, setGitHubToken, logout } = useAuth(); const { data: config } = useConfig(); - const { mutate: saveSettings } = useSaveSettings(); + const { saveUserSettings } = useCurrentSettings(); const { t } = useTranslation(); - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const formData = new FormData(event.currentTarget); @@ -50,7 +50,7 @@ export function AccountSettingsForm({ ({ label }) => label === language, )?.value; - if (languageKey) saveSettings({ LANGUAGE: languageKey }); + if (languageKey) await saveUserSettings({ LANGUAGE: languageKey }); } handleCaptureConsent(analytics); @@ -61,7 +61,7 @@ export function AccountSettingsForm({ }; return ( - +
@@ -137,6 +137,7 @@ export function AccountSettingsForm({
diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index 1265d6035514..b60bba79d7f3 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -19,10 +19,10 @@ import { CustomModelInput } from "../../inputs/custom-model-input"; import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input"; import { ModalBackdrop } from "../modal-backdrop"; import { ModelSelector } from "./model-selector"; -import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; import { RuntimeSizeSelector } from "./runtime-size-selector"; import { useConfig } from "#/hooks/query/use-config"; +import { useCurrentSettings } from "#/context/settings-context"; interface SettingsFormProps { disabled?: boolean; @@ -41,7 +41,7 @@ export function SettingsForm({ securityAnalyzers, onClose, }: SettingsFormProps) { - const { mutateAsync: saveSettings } = useSaveSettings(); + const { saveUserSettings } = useCurrentSettings(); const endSession = useEndSession(); const { data: config } = useConfig(); @@ -95,7 +95,8 @@ export function SettingsForm({ const newSettings = extractSettings(formData); saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic"); - await saveSettings(newSettings, { onSuccess: onClose }); + await saveUserSettings(newSettings); + onClose(); resetOngoingSession(); posthog.capture("settings_saved", { @@ -107,7 +108,8 @@ export function SettingsForm({ }; const handleConfirmResetSettings = async () => { - await saveSettings(getDefaultSettings(), { onSuccess: onClose }); + await saveUserSettings(getDefaultSettings()); + onClose(); resetOngoingSession(); posthog.capture("settings_reset"); }; diff --git a/frontend/src/context/settings-context.tsx b/frontend/src/context/settings-context.tsx new file mode 100644 index 000000000000..4b85d2e27784 --- /dev/null +++ b/frontend/src/context/settings-context.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { + LATEST_SETTINGS_VERSION, + Settings, + settingsAreUpToDate, +} from "#/services/settings"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; + +interface SettingsContextType { + isUpToDate: boolean; + setIsUpToDate: (value: boolean) => void; + saveUserSettings: (newSettings: Partial) => Promise; + settings: Settings | undefined; +} + +const SettingsContext = React.createContext( + undefined, +); + +interface SettingsProviderProps { + children: React.ReactNode; +} + +export function SettingsProvider({ children }: SettingsProviderProps) { + const { data: userSettings } = useSettings(); + const { mutateAsync: saveSettings } = useSaveSettings(); + + const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate()); + + const saveUserSettings = async (newSettings: Partial) => { + const updatedSettings: Partial = { + ...userSettings, + ...newSettings, + }; + await saveSettings(updatedSettings, { + onSuccess: () => { + if (!isUpToDate) { + localStorage.setItem( + "SETTINGS_VERSION", + LATEST_SETTINGS_VERSION.toString(), + ); + setIsUpToDate(true); + } + }, + }); + }; + + const value = React.useMemo( + () => ({ + isUpToDate, + setIsUpToDate, + saveUserSettings, + settings: userSettings, + }), + [isUpToDate, setIsUpToDate, saveUserSettings, userSettings], + ); + + return {children}; +} + +export function useCurrentSettings() { + const context = React.useContext(SettingsContext); + if (context === undefined) { + throw new Error( + "useCurrentSettings must be used within a SettingsProvider", + ); + } + return context; +} diff --git a/frontend/src/context/settings-up-to-date-context.tsx b/frontend/src/context/settings-up-to-date-context.tsx deleted file mode 100644 index e4a6341e262e..000000000000 --- a/frontend/src/context/settings-up-to-date-context.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import { settingsAreUpToDate } from "#/services/settings"; - -interface SettingsUpToDateContextType { - isUpToDate: boolean; - setIsUpToDate: (value: boolean) => void; -} - -const SettingsUpToDateContext = React.createContext< - SettingsUpToDateContextType | undefined ->(undefined); - -interface SettingsUpToDateProviderProps { - children: React.ReactNode; -} - -export function SettingsUpToDateProvider({ - children, -}: SettingsUpToDateProviderProps) { - const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate()); - - const value = React.useMemo( - () => ({ isUpToDate, setIsUpToDate }), - [isUpToDate, setIsUpToDate], - ); - - return ( - {children} - ); -} - -export function useSettingsUpToDate() { - const context = React.useContext(SettingsUpToDateContext); - if (context === undefined) { - throw new Error( - "useSettingsUpToDate must be used within a SettingsUpToDateProvider", - ); - } - return context; -} diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx index ba17326b4699..47f0090489cf 100644 --- a/frontend/src/entry.client.tsx +++ b/frontend/src/entry.client.tsx @@ -20,7 +20,7 @@ import toast from "react-hot-toast"; import store from "./store"; import { useConfig } from "./hooks/query/use-config"; import { AuthProvider } from "./context/auth-context"; -import { SettingsUpToDateProvider } from "./context/settings-up-to-date-context"; +import { SettingsProvider } from "./context/settings-context"; function PosthogInit() { const { data: config } = useConfig(); @@ -79,12 +79,12 @@ prepareApp().then(() => - - + + - - + + , diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index f9731e981d5b..ef334b0f1db8 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -1,12 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ApiSettings, - DEFAULT_SETTINGS, - LATEST_SETTINGS_VERSION, - Settings, -} from "#/services/settings"; +import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings"; import OpenHands from "#/api/open-hands"; -import { useSettingsUpToDate } from "#/context/settings-up-to-date-context"; const saveSettingsMutationFn = async (settings: Partial) => { const apiSettings: Partial = { @@ -24,19 +18,11 @@ const saveSettingsMutationFn = async (settings: Partial) => { export const useSaveSettings = () => { const queryClient = useQueryClient(); - const { isUpToDate, setIsUpToDate } = useSettingsUpToDate(); return useMutation({ mutationFn: saveSettingsMutationFn, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["settings"] }); - if (!isUpToDate) { - localStorage.setItem( - "SETTINGS_VERSION", - LATEST_SETTINGS_VERSION.toString(), - ); - setIsUpToDate(true); - } }, }); }; diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index a4d430ccf88d..3daa8c8780b7 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -39,7 +39,6 @@ export const useSettings = () => { const query = useQuery({ queryKey: ["settings"], queryFn: getSettingsQueryFn, - initialData: DEFAULT_SETTINGS, }); React.useEffect(() => { diff --git a/frontend/src/hooks/query/use-user-conversation.ts b/frontend/src/hooks/query/use-user-conversation.ts index 721c2f38d79d..d2aee4b6b806 100644 --- a/frontend/src/hooks/query/use-user-conversation.ts +++ b/frontend/src/hooks/query/use-user-conversation.ts @@ -1,11 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import OpenHands from "#/api/open-hands"; -import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; export const useUserConversation = (cid: string | null) => useQuery({ queryKey: ["user", "conversation", cid], queryFn: () => OpenHands.getConversation(cid!), - enabled: MULTI_CONVERSATION_UI && !!cid, + enabled: !!cid, retry: false, }); diff --git a/frontend/src/hooks/use-maybe-migrate-settings.ts b/frontend/src/hooks/use-maybe-migrate-settings.ts index 4f5bbd4712a0..a2da7ef47be5 100644 --- a/frontend/src/hooks/use-maybe-migrate-settings.ts +++ b/frontend/src/hooks/use-maybe-migrate-settings.ts @@ -1,7 +1,7 @@ // Sometimes we ship major changes, like a new default agent. import React from "react"; -import { useSettingsUpToDate } from "#/context/settings-up-to-date-context"; +import { useCurrentSettings } from "#/context/settings-context"; import { getCurrentSettingsVersion, DEFAULT_SETTINGS, @@ -12,7 +12,7 @@ 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 { mutateAsync: saveSettings } = useSaveSettings(); - const { isUpToDate } = useSettingsUpToDate(); + const { isUpToDate } = useCurrentSettings(); const maybeMigrateSettings = async () => { const currentVersion = getCurrentSettingsVersion(); diff --git a/frontend/src/hooks/use-terminal.ts b/frontend/src/hooks/use-terminal.ts index e247b8e5d499..640e58ac59fe 100644 --- a/frontend/src/hooks/use-terminal.ts +++ b/frontend/src/hooks/use-terminal.ts @@ -131,7 +131,9 @@ export const useTerminal = ({ content = content.replaceAll(secret, "*".repeat(10)); }); - terminal.current?.writeln(parseTerminalOutput(content)); + terminal.current?.writeln( + parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()), + ); if (type === "output") { terminal.current.write(`\n$ `); diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index f24c4a55ddae..9d1a42d4305d 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -6,7 +6,7 @@ import { } from "#/api/open-hands.types"; import { DEFAULT_SETTINGS } from "#/services/settings"; -const userPreferences = { +export const MOCK_USER_PREFERENCES = { settings: { llm_model: DEFAULT_SETTINGS.LLM_MODEL, llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL, @@ -169,14 +169,14 @@ export const handlers = [ return HttpResponse.json(config); }), http.get("/api/settings", async () => - HttpResponse.json(userPreferences.settings), + HttpResponse.json(MOCK_USER_PREFERENCES.settings), ), http.post("/api/settings", async ({ request }) => { const body = await request.json(); if (body) { - userPreferences.settings = { - ...userPreferences.settings, + MOCK_USER_PREFERENCES.settings = { + ...MOCK_USER_PREFERENCES.settings, // @ts-expect-error - We know this is a settings object ...body, }; diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index 182a7a98e57c..701d6b3edb1e 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -176,13 +176,15 @@ function AppContent() { - + {settings && ( + + )}
diff --git a/frontend/src/routes/_oh/route.tsx b/frontend/src/routes/_oh/route.tsx index 16492b533343..ca5868d15572 100644 --- a/frontend/src/routes/_oh/route.tsx +++ b/frontend/src/routes/_oh/route.tsx @@ -63,10 +63,10 @@ export default function MainApp() { }); React.useEffect(() => { - if (settings.LANGUAGE) { + if (settings?.LANGUAGE) { i18n.changeLanguage(settings.LANGUAGE); } - }, [settings.LANGUAGE]); + }, [settings?.LANGUAGE]); const isInWaitlist = !isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas"; diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index 8eeb5e453fb6..42bd9ec6e0d9 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -11,7 +11,7 @@ import { vi } from "vitest"; import { AppStore, RootState, rootReducer } from "./src/store"; import { AuthProvider } from "#/context/auth-context"; import { ConversationProvider } from "#/context/conversation-context"; -import { SettingsUpToDateProvider } from "#/context/settings-up-to-date-context"; +import { SettingsProvider } from "#/context/settings-context"; // Mock useParams before importing components vi.mock("react-router", async () => { @@ -67,19 +67,19 @@ export function renderWithProviders( return ( - - + + {children} - - + + ); diff --git a/openhands/core/config/README.md b/openhands/core/config/README.md index 5e3abae5b13a..c612a0824403 100644 --- a/openhands/core/config/README.md +++ b/openhands/core/config/README.md @@ -37,21 +37,17 @@ export SANDBOX_TIMEOUT='300' ## Type Handling -The `load_from_env` function attempts to cast environment variable values to the types specified in the dataclasses. It handles: +The `load_from_env` function attempts to cast environment variable values to the types specified in the models. It handles: - Basic types (str, int, bool) - Optional types (e.g., `str | None`) -- Nested dataclasses +- Nested models If type casting fails, an error is logged, and the default value is retained. ## Default Values -If an environment variable is not set, the default value specified in the dataclass is used. - -## Nested Configurations - -The `AppConfig` class contains nested configurations like `LLMConfig` and `AgentConfig`. The `load_from_env` function handles these by recursively processing nested dataclasses with updated prefixes. +If an environment variable is not set, the default value specified in the model is used. ## Security Considerations diff --git a/openhands/core/config/agent_config.py b/openhands/core/config/agent_config.py index 77e9dbc1e32d..268ae1c5a81d 100644 --- a/openhands/core/config/agent_config.py +++ b/openhands/core/config/agent_config.py @@ -1,11 +1,9 @@ -from dataclasses import dataclass, field, fields +from pydantic import BaseModel, Field from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig -from openhands.core.config.config_utils import get_field_info -@dataclass -class AgentConfig: +class AgentConfig(BaseModel): """Configuration for the agent. Attributes: @@ -22,20 +20,13 @@ class AgentConfig: condenser: Configuration for the memory condenser. Default is NoOpCondenserConfig. """ - codeact_enable_browsing: bool = True - codeact_enable_llm_editor: bool = False - codeact_enable_jupyter: bool = True - micro_agent_name: str | None = None - memory_enabled: bool = False - memory_max_threads: int = 3 - llm_config: str | None = None - use_microagents: bool = True - disabled_microagents: list[str] | None = None - condenser: CondenserConfig = field(default_factory=NoOpCondenserConfig) # type: ignore - - def defaults_to_dict(self) -> dict: - """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" - result = {} - for f in fields(self): - result[f.name] = get_field_info(f) - return result + codeact_enable_browsing: bool = Field(default=True) + codeact_enable_llm_editor: bool = Field(default=False) + codeact_enable_jupyter: bool = Field(default=True) + micro_agent_name: str | None = Field(default=None) + memory_enabled: bool = Field(default=False) + memory_max_threads: int = Field(default=3) + llm_config: str | None = Field(default=None) + use_microagents: bool = Field(default=True) + disabled_microagents: list[str] | None = Field(default=None) + condenser: CondenserConfig = Field(default_factory=NoOpCondenserConfig) diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py index 2dbb4aeaa8c4..5b18ce44fc76 100644 --- a/openhands/core/config/app_config.py +++ b/openhands/core/config/app_config.py @@ -1,20 +1,20 @@ -from dataclasses import dataclass, field, fields, is_dataclass from typing import ClassVar +from pydantic import BaseModel, Field, SecretStr + from openhands.core import logger from openhands.core.config.agent_config import AgentConfig from openhands.core.config.config_utils import ( OH_DEFAULT_AGENT, OH_MAX_ITERATIONS, - get_field_info, + model_defaults_to_dict, ) from openhands.core.config.llm_config import LLMConfig from openhands.core.config.sandbox_config import SandboxConfig from openhands.core.config.security_config import SecurityConfig -@dataclass -class AppConfig: +class AppConfig(BaseModel): """Configuration for the app. Attributes: @@ -46,37 +46,39 @@ class AppConfig: input is read line by line. When enabled, input continues until /exit command. """ - llms: dict[str, LLMConfig] = field(default_factory=dict) - agents: dict = field(default_factory=dict) - default_agent: str = OH_DEFAULT_AGENT - sandbox: SandboxConfig = field(default_factory=SandboxConfig) - security: SecurityConfig = field(default_factory=SecurityConfig) - runtime: str = 'docker' - file_store: str = 'local' - file_store_path: str = '/tmp/openhands_file_store' - trajectories_path: str | None = None - workspace_base: str | None = None - workspace_mount_path: str | None = None - workspace_mount_path_in_sandbox: str = '/workspace' - workspace_mount_rewrite: str | None = None - cache_dir: str = '/tmp/cache' - run_as_openhands: bool = True - max_iterations: int = OH_MAX_ITERATIONS - max_budget_per_task: float | None = None - e2b_api_key: str = '' - modal_api_token_id: str = '' - modal_api_token_secret: str = '' - disable_color: bool = False - jwt_secret: str = '' - debug: bool = False - file_uploads_max_file_size_mb: int = 0 - file_uploads_restrict_file_types: bool = False - file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*']) - runloop_api_key: str | None = None - cli_multiline_input: bool = False + llms: dict[str, LLMConfig] = Field(default_factory=dict) + agents: dict = Field(default_factory=dict) + default_agent: str = Field(default=OH_DEFAULT_AGENT) + sandbox: SandboxConfig = Field(default_factory=SandboxConfig) + security: SecurityConfig = Field(default_factory=SecurityConfig) + runtime: str = Field(default='docker') + file_store: str = Field(default='local') + file_store_path: str = Field(default='/tmp/openhands_file_store') + trajectories_path: str | None = Field(default=None) + workspace_base: str | None = Field(default=None) + workspace_mount_path: str | None = Field(default=None) + workspace_mount_path_in_sandbox: str = Field(default='/workspace') + workspace_mount_rewrite: str | None = Field(default=None) + cache_dir: str = Field(default='/tmp/cache') + run_as_openhands: bool = Field(default=True) + max_iterations: int = Field(default=OH_MAX_ITERATIONS) + max_budget_per_task: float | None = Field(default=None) + e2b_api_key: SecretStr | None = Field(default=None) + modal_api_token_id: SecretStr | None = Field(default=None) + modal_api_token_secret: SecretStr | None = Field(default=None) + disable_color: bool = Field(default=False) + jwt_secret: SecretStr | None = Field(default=None) + debug: bool = Field(default=False) + file_uploads_max_file_size_mb: int = Field(default=0) + file_uploads_restrict_file_types: bool = Field(default=False) + file_uploads_allowed_extensions: list[str] = Field(default_factory=lambda: ['.*']) + runloop_api_key: SecretStr | None = Field(default=None) + cli_multiline_input: bool = Field(default=False) defaults_dict: ClassVar[dict] = {} + model_config = {'extra': 'forbid'} + def get_llm_config(self, name='llm') -> LLMConfig: """'llm' is the name for default config (for backward compatibility prior to 0.8).""" if name in self.llms: @@ -115,42 +117,7 @@ def get_llm_config_from_agent(self, name='agent') -> LLMConfig: def get_agent_configs(self) -> dict[str, AgentConfig]: return self.agents - def __post_init__(self): + def model_post_init(self, __context): """Post-initialization hook, called when the instance is created with only default values.""" - AppConfig.defaults_dict = self.defaults_to_dict() - - def defaults_to_dict(self) -> dict: - """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" - result = {} - for f in fields(self): - field_value = getattr(self, f.name) - - # dataclasses compute their defaults themselves - if is_dataclass(type(field_value)): - result[f.name] = field_value.defaults_to_dict() - else: - result[f.name] = get_field_info(f) - return result - - def __str__(self): - attr_str = [] - for f in fields(self): - attr_name = f.name - attr_value = getattr(self, f.name) - - if attr_name in [ - 'e2b_api_key', - 'github_token', - 'jwt_secret', - 'modal_api_token_id', - 'modal_api_token_secret', - 'runloop_api_key', - ]: - attr_value = '******' if attr_value else None - - attr_str.append(f'{attr_name}={repr(attr_value)}') - - return f"AppConfig({', '.join(attr_str)}" - - def __repr__(self): - return self.__str__() + super().model_post_init(__context) + AppConfig.defaults_dict = model_defaults_to_dict(self) diff --git a/openhands/core/config/config_utils.py b/openhands/core/config/config_utils.py index 38c3c1d03df5..44893e119b5a 100644 --- a/openhands/core/config/config_utils.py +++ b/openhands/core/config/config_utils.py @@ -1,19 +1,22 @@ from types import UnionType -from typing import get_args, get_origin +from typing import Any, get_args, get_origin + +from pydantic import BaseModel +from pydantic.fields import FieldInfo OH_DEFAULT_AGENT = 'CodeActAgent' OH_MAX_ITERATIONS = 500 -def get_field_info(f): +def get_field_info(field: FieldInfo) -> dict[str, Any]: """Extract information about a dataclass field: type, optional, and default. Args: - f: The field to extract information from. + field: The field to extract information from. Returns: A dict with the field's type, whether it's optional, and its default value. """ - field_type = f.type + field_type = field.annotation optional = False # for types like str | None, find the non-None type and set optional to True @@ -33,7 +36,21 @@ def get_field_info(f): ) # default is always present - default = f.default + default = field.default # return a schema with the useful info for frontend return {'type': type_name.lower(), 'optional': optional, 'default': default} + + +def model_defaults_to_dict(model: BaseModel) -> dict[str, Any]: + """Serialize field information in a dict for the frontend, including type hints, defaults, and whether it's optional.""" + result = {} + for name, field in model.model_fields.items(): + field_value = getattr(model, name) + + if isinstance(field_value, BaseModel): + result[name] = model_defaults_to_dict(field_value) + else: + result[name] = get_field_info(field) + + return result diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py index 1d7dfb8f1797..81705beeb6e2 100644 --- a/openhands/core/config/llm_config.py +++ b/openhands/core/config/llm_config.py @@ -1,15 +1,14 @@ +from __future__ import annotations + import os -from dataclasses import dataclass, fields -from typing import Optional +from typing import Any -from openhands.core.config.config_utils import get_field_info -from openhands.core.logger import LOG_DIR +from pydantic import BaseModel, Field, SecretStr -LLM_SENSITIVE_FIELDS = ['api_key', 'aws_access_key_id', 'aws_secret_access_key'] +from openhands.core.logger import LOG_DIR -@dataclass -class LLMConfig: +class LLMConfig(BaseModel): """Configuration for the LLM model. Attributes: @@ -48,98 +47,57 @@ class LLMConfig: native_tool_calling: Whether to use native tool calling if supported by the model. Can be True, False, or not set. """ - model: str = 'claude-3-5-sonnet-20241022' - api_key: str | None = None - base_url: str | None = None - api_version: str | None = None - embedding_model: str = 'local' - embedding_base_url: str | None = None - embedding_deployment_name: str | None = None - aws_access_key_id: str | None = None - aws_secret_access_key: str | None = None - aws_region_name: str | None = None - openrouter_site_url: str = 'https://docs.all-hands.dev/' - openrouter_app_name: str = 'OpenHands' - num_retries: int = 8 - retry_multiplier: float = 2 - retry_min_wait: int = 15 - retry_max_wait: int = 120 - timeout: int | None = None - max_message_chars: int = 30_000 # maximum number of characters in an observation's content when sent to the llm - temperature: float = 0.0 - top_p: float = 1.0 - custom_llm_provider: str | None = None - max_input_tokens: int | None = None - max_output_tokens: int | None = None - input_cost_per_token: float | None = None - output_cost_per_token: float | None = None - ollama_base_url: str | None = None + model: str = Field(default='claude-3-5-sonnet-20241022') + api_key: SecretStr | None = Field(default=None) + base_url: str | None = Field(default=None) + api_version: str | None = Field(default=None) + embedding_model: str = Field(default='local') + embedding_base_url: str | None = Field(default=None) + embedding_deployment_name: str | None = Field(default=None) + aws_access_key_id: SecretStr | None = Field(default=None) + aws_secret_access_key: SecretStr | None = Field(default=None) + aws_region_name: str | None = Field(default=None) + openrouter_site_url: str = Field(default='https://docs.all-hands.dev/') + openrouter_app_name: str = Field(default='OpenHands') + num_retries: int = Field(default=8) + retry_multiplier: float = Field(default=2) + retry_min_wait: int = Field(default=15) + retry_max_wait: int = Field(default=120) + timeout: int | None = Field(default=None) + max_message_chars: int = Field( + default=30_000 + ) # maximum number of characters in an observation's content when sent to the llm + temperature: float = Field(default=0.0) + top_p: float = Field(default=1.0) + custom_llm_provider: str | None = Field(default=None) + max_input_tokens: int | None = Field(default=None) + max_output_tokens: int | None = Field(default=None) + input_cost_per_token: float | None = Field(default=None) + output_cost_per_token: float | None = Field(default=None) + ollama_base_url: str | None = Field(default=None) # This setting can be sent in each call to litellm - drop_params: bool = True + drop_params: bool = Field(default=True) # Note: this setting is actually global, unlike drop_params - modify_params: bool = True - disable_vision: bool | None = None - caching_prompt: bool = True - log_completions: bool = False - log_completions_folder: str = os.path.join(LOG_DIR, 'completions') - draft_editor: Optional['LLMConfig'] = None - custom_tokenizer: str | None = None - native_tool_calling: bool | None = None + modify_params: bool = Field(default=True) + disable_vision: bool | None = Field(default=None) + caching_prompt: bool = Field(default=True) + log_completions: bool = Field(default=False) + log_completions_folder: str = Field(default=os.path.join(LOG_DIR, 'completions')) + draft_editor: LLMConfig | None = Field(default=None) + custom_tokenizer: str | None = Field(default=None) + native_tool_calling: bool | None = Field(default=None) - def defaults_to_dict(self) -> dict: - """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" - result = {} - for f in fields(self): - result[f.name] = get_field_info(f) - return result + model_config = {'extra': 'forbid'} + + def model_post_init(self, __context: Any): + """Post-initialization hook to assign OpenRouter-related variables to environment variables. - def __post_init__(self): - """ - Post-initialization hook to assign OpenRouter-related variables to environment variables. This ensures that these values are accessible to litellm at runtime. """ + super().model_post_init(__context) # Assign OpenRouter-specific variables to environment variables if self.openrouter_site_url: os.environ['OR_SITE_URL'] = self.openrouter_site_url if self.openrouter_app_name: os.environ['OR_APP_NAME'] = self.openrouter_app_name - - def __str__(self): - attr_str = [] - for f in fields(self): - attr_name = f.name - attr_value = getattr(self, f.name) - - if attr_name in LLM_SENSITIVE_FIELDS: - attr_value = '******' if attr_value else None - - attr_str.append(f'{attr_name}={repr(attr_value)}') - - return f"LLMConfig({', '.join(attr_str)})" - - def __repr__(self): - return self.__str__() - - def to_safe_dict(self): - """Return a dict with the sensitive fields replaced with ******.""" - ret = self.__dict__.copy() - for k, v in ret.items(): - if k in LLM_SENSITIVE_FIELDS: - ret[k] = '******' if v else None - elif isinstance(v, LLMConfig): - ret[k] = v.to_safe_dict() - return ret - - @classmethod - def from_dict(cls, llm_config_dict: dict) -> 'LLMConfig': - """Create an LLMConfig object from a dictionary. - - This function is used to create an LLMConfig object from a dictionary, - with the exception of the 'draft_editor' key, which is a nested LLMConfig object. - """ - args = {k: v for k, v in llm_config_dict.items() if not isinstance(v, dict)} - if 'draft_editor' in llm_config_dict: - draft_editor_config = LLMConfig(**llm_config_dict['draft_editor']) - args['draft_editor'] = draft_editor_config - return cls(**args) diff --git a/openhands/core/config/sandbox_config.py b/openhands/core/config/sandbox_config.py index 3a0b705dd02d..c7d6f7369bdc 100644 --- a/openhands/core/config/sandbox_config.py +++ b/openhands/core/config/sandbox_config.py @@ -1,11 +1,9 @@ import os -from dataclasses import dataclass, field, fields -from openhands.core.config.config_utils import get_field_info +from pydantic import BaseModel, Field -@dataclass -class SandboxConfig: +class SandboxConfig(BaseModel): """Configuration for the sandbox. Attributes: @@ -39,48 +37,32 @@ class SandboxConfig: This should be a JSON string that will be parsed into a dictionary. """ - remote_runtime_api_url: str = 'http://localhost:8000' - local_runtime_url: str = 'http://localhost' - keep_runtime_alive: bool = True - rm_all_containers: bool = False - api_key: str | None = None - base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22' # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime - runtime_container_image: str | None = None - user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000 - timeout: int = 120 - remote_runtime_init_timeout: int = 180 - enable_auto_lint: bool = ( - False # once enabled, OpenHands would lint files after editing + remote_runtime_api_url: str = Field(default='http://localhost:8000') + local_runtime_url: str = Field(default='http://localhost') + keep_runtime_alive: bool = Field(default=True) + rm_all_containers: bool = Field(default=False) + api_key: str | None = Field(default=None) + base_container_image: str = Field( + default='nikolaik/python-nodejs:python3.12-nodejs22' ) - use_host_network: bool = False - runtime_extra_build_args: list[str] | None = None - initialize_plugins: bool = True - force_rebuild_runtime: bool = False - runtime_extra_deps: str | None = None - runtime_startup_env_vars: dict[str, str] = field(default_factory=dict) - browsergym_eval_env: str | None = None - platform: str | None = None - close_delay: int = 900 - remote_runtime_resource_factor: int = 1 - enable_gpu: bool = False - docker_runtime_kwargs: str | None = None - - def defaults_to_dict(self) -> dict: - """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" - dict = {} - for f in fields(self): - dict[f.name] = get_field_info(f) - return dict - - def __str__(self): - attr_str = [] - for f in fields(self): - attr_name = f.name - attr_value = getattr(self, f.name) - - attr_str.append(f'{attr_name}={repr(attr_value)}') - - return f"SandboxConfig({', '.join(attr_str)})" + runtime_container_image: str | None = Field(default=None) + user_id: int = Field(default=os.getuid() if hasattr(os, 'getuid') else 1000) + timeout: int = Field(default=120) + remote_runtime_init_timeout: int = Field(default=180) + enable_auto_lint: bool = Field( + default=False # once enabled, OpenHands would lint files after editing + ) + use_host_network: bool = Field(default=False) + runtime_extra_build_args: list[str] | None = Field(default=None) + initialize_plugins: bool = Field(default=True) + force_rebuild_runtime: bool = Field(default=False) + runtime_extra_deps: str | None = Field(default=None) + runtime_startup_env_vars: dict[str, str] = Field(default_factory=dict) + browsergym_eval_env: str | None = Field(default=None) + platform: str | None = Field(default=None) + close_delay: int = Field(default=900) + remote_runtime_resource_factor: int = Field(default=1) + enable_gpu: bool = Field(default=False) + docker_runtime_kwargs: str | None = Field(default=None) - def __repr__(self): - return self.__str__() + model_config = {'extra': 'forbid'} diff --git a/openhands/core/config/security_config.py b/openhands/core/config/security_config.py index 60645f305736..a4805e3ab85f 100644 --- a/openhands/core/config/security_config.py +++ b/openhands/core/config/security_config.py @@ -1,10 +1,7 @@ -from dataclasses import dataclass, fields +from pydantic import BaseModel, Field -from openhands.core.config.config_utils import get_field_info - -@dataclass -class SecurityConfig: +class SecurityConfig(BaseModel): """Configuration for security related functionalities. Attributes: @@ -12,29 +9,5 @@ class SecurityConfig: security_analyzer: The security analyzer to use. """ - confirmation_mode: bool = False - security_analyzer: str | None = None - - def defaults_to_dict(self) -> dict: - """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" - dict = {} - for f in fields(self): - dict[f.name] = get_field_info(f) - return dict - - def __str__(self): - attr_str = [] - for f in fields(self): - attr_name = f.name - attr_value = getattr(self, f.name) - - attr_str.append(f'{attr_name}={repr(attr_value)}') - - return f"SecurityConfig({', '.join(attr_str)})" - - @classmethod - def from_dict(cls, security_config_dict: dict) -> 'SecurityConfig': - return cls(**security_config_dict) - - def __repr__(self): - return self.__str__() + confirmation_mode: bool = Field(default=False) + security_analyzer: str | None = Field(default=None) diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index 7719ce0d59b1..b8461b420d73 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -3,13 +3,13 @@ import pathlib import platform import sys -from dataclasses import is_dataclass from types import UnionType from typing import Any, MutableMapping, get_args, get_origin from uuid import uuid4 import toml from dotenv import load_dotenv +from pydantic import BaseModel, ValidationError from openhands.core import logger from openhands.core.config.agent_config import AgentConfig @@ -43,17 +43,19 @@ def get_optional_type(union_type: UnionType) -> Any: return next((t for t in types if t is not type(None)), None) # helper function to set attributes based on env vars - def set_attr_from_env(sub_config: Any, prefix=''): - """Set attributes of a config dataclass based on environment variables.""" - for field_name, field_type in sub_config.__annotations__.items(): + def set_attr_from_env(sub_config: BaseModel, prefix=''): + """Set attributes of a config model based on environment variables.""" + for field_name, field_info in sub_config.model_fields.items(): + field_value = getattr(sub_config, field_name) + field_type = field_info.annotation + # compute the expected env var name from the prefix and field name # e.g. LLM_BASE_URL env_var_name = (prefix + field_name).upper() - if is_dataclass(field_type): - # nested dataclass - nested_sub_config = getattr(sub_config, field_name) - set_attr_from_env(nested_sub_config, prefix=field_name + '_') + if isinstance(field_value, BaseModel): + set_attr_from_env(field_value, prefix=field_name + '_') + elif env_var_name in env_or_toml_dict: # convert the env var to the correct type and set it value = env_or_toml_dict[env_var_name] @@ -126,45 +128,60 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): if isinstance(value, dict): try: if key is not None and key.lower() == 'agent': + # Every entry here is either a field for the default `agent` config group, or itself a group + # The best way to tell the difference is to try to parse it as an AgentConfig object + agent_group_ids: set[str] = set() + for nested_key, nested_value in value.items(): + if isinstance(nested_value, dict): + try: + agent_config = AgentConfig(**nested_value) + except ValidationError: + continue + agent_group_ids.add(nested_key) + cfg.set_agent_config(agent_config, nested_key) + logger.openhands_logger.debug( 'Attempt to load default agent config from config toml' ) - non_dict_fields = { - k: v for k, v in value.items() if not isinstance(v, dict) + value_without_groups = { + k: v for k, v in value.items() if k not in agent_group_ids } - agent_config = AgentConfig(**non_dict_fields) + agent_config = AgentConfig(**value_without_groups) cfg.set_agent_config(agent_config, 'agent') + + elif key is not None and key.lower() == 'llm': + # Every entry here is either a field for the default `llm` config group, or itself a group + # The best way to tell the difference is to try to parse it as an LLMConfig object + llm_group_ids: set[str] = set() for nested_key, nested_value in value.items(): if isinstance(nested_value, dict): - logger.openhands_logger.debug( - f'Attempt to load group {nested_key} from config toml as agent config' - ) - agent_config = AgentConfig(**nested_value) - cfg.set_agent_config(agent_config, nested_key) - elif key is not None and key.lower() == 'llm': + try: + llm_config = LLMConfig(**nested_value) + except ValidationError: + continue + llm_group_ids.add(nested_key) + cfg.set_llm_config(llm_config, nested_key) + logger.openhands_logger.debug( 'Attempt to load default LLM config from config toml' ) - llm_config = LLMConfig.from_dict(value) + value_without_groups = { + k: v for k, v in value.items() if k not in llm_group_ids + } + llm_config = LLMConfig(**value_without_groups) cfg.set_llm_config(llm_config, 'llm') - for nested_key, nested_value in value.items(): - if isinstance(nested_value, dict): - logger.openhands_logger.debug( - f'Attempt to load group {nested_key} from config toml as llm config' - ) - llm_config = LLMConfig.from_dict(nested_value) - cfg.set_llm_config(llm_config, nested_key) + elif key is not None and key.lower() == 'security': logger.openhands_logger.debug( 'Attempt to load security config from config toml' ) - security_config = SecurityConfig.from_dict(value) + security_config = SecurityConfig(**value) cfg.security = security_config elif not key.startswith('sandbox') and key.lower() != 'core': logger.openhands_logger.warning( f'Unknown key in {toml_file}: "{key}"' ) - except (TypeError, KeyError) as e: + except (TypeError, KeyError, ValidationError) as e: logger.openhands_logger.warning( f'Cannot parse [{key}] config from toml, values have not been applied.\nError: {e}', exc_info=False, @@ -201,7 +218,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): logger.openhands_logger.warning( f'Unknown config key "{key}" in [core] section' ) - except (TypeError, KeyError) as e: + except (TypeError, KeyError, ValidationError) as e: logger.openhands_logger.warning( f'Cannot parse [sandbox] config from toml, values have not been applied.\nError: {e}', exc_info=False, @@ -305,7 +322,7 @@ def get_llm_config_arg( # update the llm config with the specified section if 'llm' in toml_config and llm_config_arg in toml_config['llm']: - return LLMConfig.from_dict(toml_config['llm'][llm_config_arg]) + return LLMConfig(**toml_config['llm'][llm_config_arg]) logger.openhands_logger.debug(f'Loading from toml failed for {llm_config_arg}') return None diff --git a/openhands/events/event.py b/openhands/events/event.py index 79026627354e..6c7a2d8a3ac1 100644 --- a/openhands/events/event.py +++ b/openhands/events/event.py @@ -67,6 +67,13 @@ def timeout(self) -> int | None: @timeout.setter def timeout(self, value: int | None) -> None: self._timeout = value + if value is not None and value > 600: + from openhands.core.logger import openhands_logger as logger + + logger.warning( + 'Timeout greater than 600 seconds may not be supported by ' + 'the runtime. Consider setting a lower timeout.' + ) # Check if .blocking is an attribute of the event if hasattr(self, 'blocking'): diff --git a/openhands/llm/async_llm.py b/openhands/llm/async_llm.py index ed84273c737b..97b2f9874829 100644 --- a/openhands/llm/async_llm.py +++ b/openhands/llm/async_llm.py @@ -19,7 +19,9 @@ def __init__(self, *args, **kwargs): self._async_completion = partial( self._call_acompletion, model=self.config.model, - api_key=self.config.api_key, + api_key=self.config.api_key.get_secret_value() + if self.config.api_key + else None, base_url=self.config.base_url, api_version=self.config.api_version, custom_llm_provider=self.config.custom_llm_provider, diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index 743d6535ba3b..552d631b0201 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -132,7 +132,9 @@ def __init__( self._completion = partial( litellm_completion, model=self.config.model, - api_key=self.config.api_key, + api_key=self.config.api_key.get_secret_value() + if self.config.api_key + else None, base_url=self.config.base_url, api_version=self.config.api_version, custom_llm_provider=self.config.custom_llm_provider, @@ -318,7 +320,9 @@ def init_model_info(self): # GET {base_url}/v1/model/info with litellm_model_id as path param response = requests.get( f'{self.config.base_url}/v1/model/info', - headers={'Authorization': f'Bearer {self.config.api_key}'}, + headers={ + 'Authorization': f'Bearer {self.config.api_key.get_secret_value() if self.config.api_key else None}' + }, ) resp_json = response.json() if 'data' not in resp_json: diff --git a/openhands/llm/streaming_llm.py b/openhands/llm/streaming_llm.py index 77d999fadcd3..f91eb3203dfd 100644 --- a/openhands/llm/streaming_llm.py +++ b/openhands/llm/streaming_llm.py @@ -16,7 +16,9 @@ def __init__(self, *args, **kwargs): self._async_streaming_completion = partial( self._call_acompletion, model=self.config.model, - api_key=self.config.api_key, + api_key=self.config.api_key.get_secret_value() + if self.config.api_key + else None, base_url=self.config.base_url, api_version=self.config.api_version, custom_llm_provider=self.config.custom_llm_provider, diff --git a/openhands/resolver/patching/patch.py b/openhands/resolver/patching/patch.py index 7e3b98ed0883..82c67c1b756a 100644 --- a/openhands/resolver/patching/patch.py +++ b/openhands/resolver/patching/patch.py @@ -24,7 +24,7 @@ unified_header_old_line = re.compile(r'^--- ' + file_timestamp_str + '$') unified_header_new_line = re.compile(r'^\+\+\+ ' + file_timestamp_str + '$') unified_hunk_start = re.compile(r'^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$') -unified_change = re.compile('^([-+ ])(.*)$') +unified_change = re.compile('^([-+ ])(.*)$', re.MULTILINE) context_header_old_line = re.compile(r'^\*\*\* ' + file_timestamp_str + '$') context_header_new_line = re.compile('^--- ' + file_timestamp_str + '$') @@ -606,38 +606,39 @@ def parse_unified_diff(text): h = unified_hunk_start.match(hunk[0]) del hunk[0] if h: - old = int(h.group(1)) - if len(h.group(2)) > 0: - old_len = int(h.group(2)) - else: - old_len = 0 + # The hunk header @@ -1,6 +1,6 @@ means: + # - Start at line 1 in the old file and show 6 lines + # - Start at line 1 in the new file and show 6 lines + old = int(h.group(1)) # Starting line in old file + old_len = int(h.group(2)) if len(h.group(2)) > 0 else 1 # Number of lines in old file - new = int(h.group(3)) - if len(h.group(4)) > 0: - new_len = int(h.group(4)) - else: - new_len = 0 + new = int(h.group(3)) # Starting line in new file + new_len = int(h.group(4)) if len(h.group(4)) > 0 else 1 # Number of lines in new file h = None break + # Process each line in the hunk for n in hunk: - c = unified_change.match(n) - if c: - kind = c.group(1) - line = c.group(2) - - if kind == '-' and (r != old_len or r == 0): - changes.append(Change(old + r, None, line, hunk_n)) - r += 1 - elif kind == '+' and (i != new_len or i == 0): - changes.append(Change(None, new + i, line, hunk_n)) - i += 1 - elif kind == ' ': - if r != old_len and i != new_len: - changes.append(Change(old + r, new + i, line, hunk_n)) - r += 1 - i += 1 + # Each line in a unified diff starts with a space (context), + (addition), or - (deletion) + # The first character is the kind, the rest is the line content + kind = n[0] if len(n) > 0 else ' ' # Empty lines in the hunk are treated as context lines + line = n[1:] if len(n) > 1 else '' + + # Process the line based on its kind + if kind == '-' and (r != old_len or r == 0): + # Line was removed from the old file + changes.append(Change(old + r, None, line, hunk_n)) + r += 1 + elif kind == '+' and (i != new_len or i == 0): + # Line was added in the new file + changes.append(Change(None, new + i, line, hunk_n)) + i += 1 + elif kind == ' ': + # Context line - exists in both old and new file + changes.append(Change(old + r, new + i, line, hunk_n)) + r += 1 + i += 1 if len(changes) > 0: return changes diff --git a/openhands/resolver/resolve_issue.py b/openhands/resolver/resolve_issue.py index 21036dbc29b8..f50b37d79447 100644 --- a/openhands/resolver/resolve_issue.py +++ b/openhands/resolver/resolve_issue.py @@ -14,12 +14,7 @@ import openhands from openhands.controller.state.state import State -from openhands.core.config import ( - AgentConfig, - AppConfig, - LLMConfig, - SandboxConfig, -) +from openhands.core.config import AgentConfig, AppConfig, LLMConfig, SandboxConfig from openhands.core.logger import openhands_logger as logger from openhands.core.main import create_runtime, run_controller from openhands.events.action import CmdRunAction, MessageAction @@ -153,7 +148,7 @@ async def process_issue( max_iterations: int, llm_config: LLMConfig, output_dir: str, - runtime_container_image: str, + runtime_container_image: str | None, prompt_template: str, issue_handler: IssueHandlerInterface, repo_instruction: str | None = None, @@ -306,7 +301,7 @@ async def resolve_issue( max_iterations: int, output_dir: str, llm_config: LLMConfig, - runtime_container_image: str, + runtime_container_image: str | None, prompt_template: str, issue_type: str, repo_instruction: str | None, @@ -583,11 +578,16 @@ def int_or_none(value): default=None, help="Target branch to pull and create PR against (for PRs). If not specified, uses the PR's base branch.", ) + parser.add_argument( + '--is-experimental', + type=lambda x: x.lower() == 'true', + help='Whether to run in experimental mode.', + ) my_args = parser.parse_args() runtime_container_image = my_args.runtime_container_image - if runtime_container_image is None: + if runtime_container_image is None and not my_args.is_experimental: runtime_container_image = ( f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik' ) diff --git a/openhands/runtime/impl/docker/docker_runtime.py b/openhands/runtime/impl/docker/docker_runtime.py index 6eb51418b733..5111f0f36831 100644 --- a/openhands/runtime/impl/docker/docker_runtime.py +++ b/openhands/runtime/impl/docker/docker_runtime.py @@ -21,6 +21,7 @@ from openhands.runtime.impl.docker.containers import remove_all_containers from openhands.runtime.plugins import PluginRequirement from openhands.runtime.utils import find_available_tcp_port +from openhands.runtime.utils.command import get_action_execution_server_startup_command from openhands.runtime.utils.log_streamer import LogStreamer from openhands.runtime.utils.runtime_build import build_runtime_image from openhands.utils.async_utils import call_sync_from_async @@ -186,11 +187,7 @@ def _init_docker_client() -> docker.DockerClient: def _init_container(self): self.log('debug', 'Preparing to start container...') self.send_status_message('STATUS$PREPARING_CONTAINER') - plugin_arg = '' - if self.plugins is not None and len(self.plugins) > 0: - plugin_arg = ( - f'--plugins {" ".join([plugin.name for plugin in self.plugins])} ' - ) + self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE) self._container_port = self._host_port self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE) @@ -203,8 +200,6 @@ def _init_container(self): use_host_network = self.config.sandbox.use_host_network network_mode: str | None = 'host' if use_host_network else None - use_host_network = self.config.sandbox.use_host_network - # Initialize port mappings port_mapping: dict[str, list[dict[str, str]]] | None = None if not use_host_network: @@ -257,26 +252,17 @@ def _init_container(self): f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}', ) - if self.config.sandbox.browsergym_eval_env is not None: - browsergym_arg = ( - f'--browsergym-eval-env {self.config.sandbox.browsergym_eval_env}' - ) - else: - browsergym_arg = '' + command = get_action_execution_server_startup_command( + server_port=self._container_port, + plugins=self.plugins, + app_config=self.config, + use_nice_for_root=False, + ) try: self.container = self.docker_client.containers.run( self.runtime_container_image, - command=( - f'/openhands/micromamba/bin/micromamba run -n openhands ' - f'poetry run ' - f'python -u -m openhands.runtime.action_execution_server {self._container_port} ' - f'--working-dir "{self.config.workspace_mount_path_in_sandbox}" ' - f'{plugin_arg}' - f'--username {"openhands" if self.config.run_as_openhands else "root"} ' - f'--user-id {self.config.sandbox.user_id} ' - f'{browsergym_arg}' - ), + command=command, network_mode=network_mode, ports=port_mapping, working_dir='/openhands/code/', # do not change this! diff --git a/openhands/runtime/impl/modal/modal_runtime.py b/openhands/runtime/impl/modal/modal_runtime.py index 473c4ae97b10..61e72205a7f8 100644 --- a/openhands/runtime/impl/modal/modal_runtime.py +++ b/openhands/runtime/impl/modal/modal_runtime.py @@ -13,7 +13,7 @@ ActionExecutionClient, ) from openhands.runtime.plugins import PluginRequirement -from openhands.runtime.utils.command import get_remote_startup_command +from openhands.runtime.utils.command import get_action_execution_server_startup_command from openhands.runtime.utils.runtime_build import ( BuildFromImageType, prep_build_folder, @@ -59,7 +59,8 @@ def __init__( self.sandbox = None self.modal_client = modal.Client.from_credentials( - config.modal_api_token_id, config.modal_api_token_secret + config.modal_api_token_id.get_secret_value(), + config.modal_api_token_secret.get_secret_value(), ) self.app = modal.App.lookup( 'openhands', create_if_missing=True, client=self.modal_client @@ -203,11 +204,6 @@ def _init_sandbox( ): try: self.log('debug', 'Preparing to start container...') - plugin_args = [] - if plugins is not None and len(plugins) > 0: - plugin_args.append('--plugins') - plugin_args.extend([plugin.name for plugin in plugins]) - # Combine environment variables environment: dict[str, str | None] = { 'port': str(self.container_port), @@ -216,24 +212,13 @@ def _init_sandbox( if self.config.debug: environment['DEBUG'] = 'true' - browsergym_args = [] - if self.config.sandbox.browsergym_eval_env is not None: - browsergym_args = [ - '-browsergym-eval-env', - self.config.sandbox.browsergym_eval_env, - ] - env_secret = modal.Secret.from_dict(environment) self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}') - sandbox_start_cmd = get_remote_startup_command( - self.container_port, - sandbox_workspace_dir, - 'openhands' if self.config.run_as_openhands else 'root', - self.config.sandbox.user_id, - plugin_args, - browsergym_args, - is_root=not self.config.run_as_openhands, # is_root=True when running as root + sandbox_start_cmd = get_action_execution_server_startup_command( + server_port=self.container_port, + plugins=self.plugins, + app_config=self.config, ) self.log('debug', f'Starting container with command: {sandbox_start_cmd}') self.sandbox = modal.Sandbox.create( diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py index 0e0b7adc79e6..ebc1a86b384b 100644 --- a/openhands/runtime/impl/remote/remote_runtime.py +++ b/openhands/runtime/impl/remote/remote_runtime.py @@ -19,7 +19,7 @@ ActionExecutionClient, ) from openhands.runtime.plugins import PluginRequirement -from openhands.runtime.utils.command import get_remote_startup_command +from openhands.runtime.utils.command import get_action_execution_server_startup_command from openhands.runtime.utils.request import send_request from openhands.runtime.utils.runtime_build import build_runtime_image from openhands.utils.async_utils import call_sync_from_async @@ -194,22 +194,10 @@ def _build_runtime(self): def _start_runtime(self): # Prepare the request body for the /start endpoint - plugin_args = [] - if self.plugins is not None and len(self.plugins) > 0: - plugin_args = ['--plugins'] + [plugin.name for plugin in self.plugins] - browsergym_args = [] - if self.config.sandbox.browsergym_eval_env is not None: - browsergym_args = [ - '--browsergym-eval-env' - ] + self.config.sandbox.browsergym_eval_env.split(' ') - command = get_remote_startup_command( - self.port, - self.config.workspace_mount_path_in_sandbox, - 'openhands' if self.config.run_as_openhands else 'root', - self.config.sandbox.user_id, - plugin_args, - browsergym_args, - is_root=not self.config.run_as_openhands, # is_root=True when running as root + command = get_action_execution_server_startup_command( + server_port=self.port, + plugins=self.plugins, + app_config=self.config, ) start_request = { 'image': self.container_image, diff --git a/openhands/runtime/impl/runloop/runloop_runtime.py b/openhands/runtime/impl/runloop/runloop_runtime.py index 93f019561ff0..add4619aea81 100644 --- a/openhands/runtime/impl/runloop/runloop_runtime.py +++ b/openhands/runtime/impl/runloop/runloop_runtime.py @@ -13,7 +13,7 @@ ActionExecutionClient, ) from openhands.runtime.plugins import PluginRequirement -from openhands.runtime.utils.command import get_remote_startup_command +from openhands.runtime.utils.command import get_action_execution_server_startup_command from openhands.utils.tenacity_stop import stop_if_should_exit CONTAINER_NAME_PREFIX = 'openhands-runtime-' @@ -40,7 +40,7 @@ def __init__( self.devbox: DevboxView | None = None self.config = config self.runloop_api_client = Runloop( - bearer_token=config.runloop_api_key, + bearer_token=config.runloop_api_key.get_secret_value(), ) self.container_name = CONTAINER_NAME_PREFIX + sid super().__init__( @@ -78,28 +78,10 @@ def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView: def _create_new_devbox(self) -> DevboxView: # Note: Runloop connect - sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox - plugin_args = [] - if self.plugins is not None and len(self.plugins) > 0: - plugin_args.append('--plugins') - plugin_args.extend([plugin.name for plugin in self.plugins]) - - browsergym_args = [] - if self.config.sandbox.browsergym_eval_env is not None: - browsergym_args = [ - '-browsergym-eval-env', - self.config.sandbox.browsergym_eval_env, - ] - - # Copied from EventstreamRuntime - start_command = get_remote_startup_command( - self._sandbox_port, - sandbox_workspace_dir, - 'openhands' if self.config.run_as_openhands else 'root', - self.config.sandbox.user_id, - plugin_args, - browsergym_args, - is_root=not self.config.run_as_openhands, # is_root=True when running as root + start_command = get_action_execution_server_startup_command( + server_port=self._sandbox_port, + plugins=self.plugins, + app_config=self.config, ) # Add some additional commands based on our image diff --git a/openhands/runtime/utils/bash.py b/openhands/runtime/utils/bash.py index 70a24e2189f7..351d990dcda6 100644 --- a/openhands/runtime/utils/bash.py +++ b/openhands/runtime/utils/bash.py @@ -486,18 +486,6 @@ def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservati last_change_time = start_time last_pane_output = self._get_pane_content() - _ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output) - assert len(_ps1_matches) >= 1, ( - 'Expected at least one PS1 metadata block BEFORE the execution of a command, ' - f'but got {len(_ps1_matches)} PS1 metadata blocks:\n---\n{last_pane_output!r}\n---' - ) - if len(_ps1_matches) > 1: - logger.warning( - 'Found multiple PS1 metadata blocks BEFORE the execution of a command. ' - 'Only the last one will be used.' - ) - _ps1_matches = [_ps1_matches[-1]] - if command != '': # convert command to raw string command = escape_bash_special_chars(command) diff --git a/openhands/runtime/utils/command.py b/openhands/runtime/utils/command.py index 3a32d45fb7e1..76722daca476 100644 --- a/openhands/runtime/utils/command.py +++ b/openhands/runtime/utils/command.py @@ -1,35 +1,57 @@ -def get_remote_startup_command( - port: int, - sandbox_workspace_dir: str, - username: str, - user_id: int, - plugin_args: list[str], - browsergym_args: list[str], - is_root: bool = False, +from openhands.core.config import AppConfig +from openhands.runtime.plugins import PluginRequirement + +DEFAULT_PYTHON_PREFIX = [ + '/openhands/micromamba/bin/micromamba', + 'run', + '-n', + 'openhands', + 'poetry', + 'run', +] + + +def get_action_execution_server_startup_command( + server_port: int, + plugins: list[PluginRequirement], + app_config: AppConfig, + python_prefix: list[str] = DEFAULT_PYTHON_PREFIX, + use_nice_for_root: bool = True, ): + sandbox_config = app_config.sandbox + + # Plugin args + plugin_args = [] + if plugins is not None and len(plugins) > 0: + plugin_args = ['--plugins'] + [plugin.name for plugin in plugins] + + # Browsergym stuffs + browsergym_args = [] + if sandbox_config.browsergym_eval_env is not None: + browsergym_args = [ + '--browsergym-eval-env' + ] + sandbox_config.browsergym_eval_env.split(' ') + + is_root = not app_config.run_as_openhands + base_cmd = [ - '/openhands/micromamba/bin/micromamba', - 'run', - '-n', - 'openhands', - 'poetry', - 'run', + *python_prefix, 'python', '-u', '-m', 'openhands.runtime.action_execution_server', - str(port), + str(server_port), '--working-dir', - sandbox_workspace_dir, + app_config.workspace_mount_path_in_sandbox, *plugin_args, '--username', - username, + 'openhands' if app_config.run_as_openhands else 'root', '--user-id', - str(user_id), + str(sandbox_config.user_id), *browsergym_args, ] - if is_root: + if is_root and use_nice_for_root: # If running as root, set highest priority and lowest OOM score cmd_str = ' '.join(base_cmd) return [ @@ -41,5 +63,5 @@ def get_remote_startup_command( f'echo -1000 > /proc/self/oom_score_adj && exec {cmd_str}', ] else: - # If not root, run with normal priority + # If not root OR not using nice for root, run with normal priority return base_cmd diff --git a/openhands/runtime/utils/request.py b/openhands/runtime/utils/request.py index a145bd27f4e5..2a0e17aa0fba 100644 --- a/openhands/runtime/utils/request.py +++ b/openhands/runtime/utils/request.py @@ -21,7 +21,7 @@ def __str__(self) -> str: return s -def is_rate_limit_error(exception): +def is_retryable_error(exception): return ( isinstance(exception, requests.HTTPError) and exception.response.status_code == 429 @@ -29,7 +29,7 @@ def is_rate_limit_error(exception): @retry( - retry=retry_if_exception(is_rate_limit_error), + retry=retry_if_exception(is_retryable_error), stop=stop_after_attempt(3) | stop_if_should_exit(), wait=wait_exponential(multiplier=1, min=4, max=60), ) diff --git a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 index 8a97792d9de3..2dfd96050515 100644 --- a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 +++ b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 @@ -16,12 +16,12 @@ ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \ RUN apt-get update && \ apt-get install -y --no-install-recommends \ wget curl sudo apt-utils git jq tmux \ - {% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %} + {%- if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) -%} libgl1 \ - {% else %} + {%- else %} libgl1-mesa-glx \ - {% endif %} - libasound2-plugins libatomic1 curl && \ + {% endif -%} + libasound2-plugins libatomic1 && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/openhands/server/routes/public.py b/openhands/server/routes/public.py index 5a8925b741b4..fcdb1e52cef7 100644 --- a/openhands/server/routes/public.py +++ b/openhands/server/routes/public.py @@ -51,8 +51,8 @@ async def get_litellm_models() -> list[str]: ): bedrock_model_list = bedrock.list_foundation_models( llm_config.aws_region_name, - llm_config.aws_access_key_id, - llm_config.aws_secret_access_key, + llm_config.aws_access_key_id.get_secret_value(), + llm_config.aws_secret_access_key.get_secret_value(), ) model_list = litellm_model_list_without_bedrock + bedrock_model_list for llm_config in config.llms.values(): diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py index c7577cc1b558..9e7f7d8b8d7f 100644 --- a/openhands/server/session/manager.py +++ b/openhands/server/session/manager.py @@ -165,10 +165,9 @@ async def _process_message(self, message: dict): # which can't be guaranteed - nodes can simply vanish unexpectedly! sid = data['sid'] logger.debug(f'session_closing:{sid}') - for ( - connection_id, - local_sid, - ) in self.local_connection_id_to_session_id.items(): + # Create a list of items to process to avoid modifying dict during iteration + items = list(self.local_connection_id_to_session_id.items()) + for connection_id, local_sid in items: if sid == local_sid: logger.warning( 'local_connection_to_closing_session:{connection_id}:{sid}' diff --git a/openhands/utils/embeddings.py b/openhands/utils/embeddings.py index 7e251f0e5022..6791787d3204 100644 --- a/openhands/utils/embeddings.py +++ b/openhands/utils/embeddings.py @@ -90,7 +90,9 @@ def get_embedding_model(strategy: str, llm_config: LLMConfig) -> 'BaseEmbedding' return OpenAIEmbedding( model='text-embedding-ada-002', - api_key=llm_config.api_key, + api_key=llm_config.api_key.get_secret_value() + if llm_config.api_key + else None, ) elif strategy == 'azureopenai': from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding diff --git a/poetry.lock b/poetry.lock index b132d1f7b69e..51f9746cac06 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -3859,13 +3859,13 @@ llama-index-llms-azure-openai = ">=0.3.0,<0.4.0" [[package]] name = "llama-index-embeddings-huggingface" -version = "0.4.0" +version = "0.5.0" description = "llama-index embeddings huggingface integration" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index_embeddings_huggingface-0.4.0-py3-none-any.whl", hash = "sha256:a5890bab349b118398054138b298a9e429776b85bcf8017fdf01cd5d60fbba12"}, - {file = "llama_index_embeddings_huggingface-0.4.0.tar.gz", hash = "sha256:ce8f8b30b29cff85401aba2118285fb63fb8147a56b656ee20f7e8510ca085a2"}, + {file = "llama_index_embeddings_huggingface-0.5.0-py3-none-any.whl", hash = "sha256:70634b2cfaad28103b5125971fc98118f1bc404cb6145744b55de4ed54b0ad99"}, + {file = "llama_index_embeddings_huggingface-0.5.0.tar.gz", hash = "sha256:bb75924bd52631364bd3b1a4b0ab78753a0bef00210f2762b425cbd05f4ea60e"}, ] [package.dependencies] @@ -5389,17 +5389,15 @@ realtime = ["websockets (>=13,<15)"] [[package]] name = "openhands-aci" -version = "0.1.6" +version = "0.1.8" description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands." optional = false -python-versions = "<4.0,>=3.12" -files = [ - {file = "openhands_aci-0.1.6-py3-none-any.whl", hash = "sha256:e9589d959a146fad3e6935be1f80b7a4368dd7aa2ba38ad267862c4f8a246e72"}, - {file = "openhands_aci-0.1.6.tar.gz", hash = "sha256:6edf4d6478a349140a324c4a0c4be6d1e9a7acce1739a37d02eecbb9006a2ce7"}, -] +python-versions = "^3.12" +files = [] +develop = false [package.dependencies] -diskcache = ">=5.6.3,<6.0.0" +diskcache = "^5.6.3" flake8 = "*" gitpython = "*" grep-ast = "0.3.3" @@ -5409,7 +5407,13 @@ numpy = "*" pandas = "*" scipy = "*" tree-sitter = "0.21.3" -whatthepatch = ">=1.0.6,<2.0.0" +whatthepatch = "^1.0.6" + +[package.source] +type = "git" +url = "https://github.com/All-Hands-AI/openhands-aci.git" +reference = "fix-find-show-only-hidden-subpaths" +resolved_reference = "910e8c470aff0e496bf262bc673c7ee7b4531159" [[package]] name = "opentelemetry-api" @@ -9853,4 +9857,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "239681e32cbe17b32855c0bccaf636cc05c55a5411fdb79d180ab3ad833284ea" +content-hash = "8320b6c6bb05538516a965589ce03fec4d30df38fb7b47fc934258f1d8d47e30" diff --git a/pyproject.toml b/pyproject.toml index 1a15ae754ca3..af177c2a6fd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ runloop-api-client = "0.12.0" libtmux = ">=0.37,<0.40" pygithub = "^2.5.0" joblib = "*" -openhands-aci = "0.1.6" +openhands-aci = "0.1.8" python-socketio = "^5.11.4" redis = "^5.2.0" sse-starlette = "^2.1.3" @@ -101,6 +101,7 @@ reportlab = "*" [tool.coverage.run] concurrency = ["gevent"] + [tool.poetry.group.runtime.dependencies] jupyterlab = "*" notebook = "*" @@ -129,6 +130,7 @@ ignore = ["D1"] [tool.ruff.lint.pydocstyle] convention = "google" + [tool.poetry.group.evaluation.dependencies] streamlit = "*" whatthepatch = "*" diff --git a/tests/unit/resolver/test_patch_apply.py b/tests/unit/resolver/test_patch_apply.py new file mode 100644 index 000000000000..3528483cb148 --- /dev/null +++ b/tests/unit/resolver/test_patch_apply.py @@ -0,0 +1,47 @@ +import pytest +from openhands.resolver.patching.apply import apply_diff +from openhands.resolver.patching.exceptions import HunkApplyException +from openhands.resolver.patching.patch import parse_diff, diffobj + + +def test_patch_apply_with_empty_lines(): + # The original file has no indentation and uses \n line endings + original_content = "# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup" + + # The patch has spaces at the start of each line and uses \n line endings + patch = """diff --git a/README.md b/README.md +index b760a53..5071727 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,3 @@ + # PR Viewer + +-This React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization. ++This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.""" + + print("Original content lines:") + for i, line in enumerate(original_content.splitlines(), 1): + print(f"{i}: {repr(line)}") + + print("\nPatch lines:") + for i, line in enumerate(patch.splitlines(), 1): + print(f"{i}: {repr(line)}") + + changes = parse_diff(patch) + print("\nParsed changes:") + for change in changes: + print(f"Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})") + diff = diffobj(header=None, changes=changes, text=patch) + + # Apply the patch + result = apply_diff(diff, original_content) + + # The patch should be applied successfully + expected_result = [ + "# PR Viewer", + "", + "This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.", + "", + "## Setup" + ] + assert result == expected_result \ No newline at end of file diff --git a/tests/unit/test_acompletion.py b/tests/unit/test_acompletion.py index b6753759be3d..cca18bbb5b29 100644 --- a/tests/unit/test_acompletion.py +++ b/tests/unit/test_acompletion.py @@ -109,9 +109,6 @@ async def mock_on_cancel_requested(): print(f'Cancel requested: {is_set}') return is_set - config = load_app_config() - config.on_cancel_requested_fn = mock_on_cancel_requested - async def mock_acompletion(*args, **kwargs): print('Starting mock_acompletion') for i in range(20): # Increased iterations for longer running task @@ -153,13 +150,6 @@ async def cancel_after_delay(): async def test_async_streaming_completion_with_user_cancellation(cancel_after_chunks): cancel_requested = False - async def mock_on_cancel_requested(): - nonlocal cancel_requested - return cancel_requested - - config = load_app_config() - config.on_cancel_requested_fn = mock_on_cancel_requested - test_messages = [ 'This is ', 'a test ', diff --git a/tests/unit/test_codeact_agent.py b/tests/unit/test_codeact_agent.py index b1f5e420c3b4..82db18c1fd12 100644 --- a/tests/unit/test_codeact_agent.py +++ b/tests/unit/test_codeact_agent.py @@ -60,7 +60,6 @@ def mock_state() -> State: def test_cmd_output_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = CmdOutputObservation( command='echo hello', content='Command output', @@ -82,7 +81,6 @@ def test_cmd_output_observation_message(agent: CodeActAgent): def test_ipython_run_cell_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = IPythonRunCellObservation( code='plt.plot()', content='IPython output\n![image](data:image/png;base64,ABC123)', @@ -105,7 +103,6 @@ def test_ipython_run_cell_observation_message(agent: CodeActAgent): def test_agent_delegate_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = AgentDelegateObservation( content='Content', outputs={'content': 'Delegated agent output'} ) @@ -122,7 +119,6 @@ def test_agent_delegate_observation_message(agent: CodeActAgent): def test_error_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = ErrorObservation('Error message') results = agent.get_observation_message(obs, tool_call_id_to_message={}) @@ -145,7 +141,6 @@ def test_unknown_observation_message(agent: CodeActAgent): def test_file_edit_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = FileEditObservation( path='/test/file.txt', prev_exist=True, @@ -167,7 +162,6 @@ def test_file_edit_observation_message(agent: CodeActAgent): def test_file_read_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = FileReadObservation( path='/test/file.txt', content='File content', @@ -186,7 +180,6 @@ def test_file_read_observation_message(agent: CodeActAgent): def test_browser_output_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = BrowserOutputObservation( url='http://example.com', trigger_by_action='browse', @@ -207,7 +200,6 @@ def test_browser_output_observation_message(agent: CodeActAgent): def test_user_reject_observation_message(agent: CodeActAgent): - agent.config.function_calling = False obs = UserRejectObservation('Action rejected') results = agent.get_observation_message(obs, tool_call_id_to_message={}) @@ -223,7 +215,6 @@ def test_user_reject_observation_message(agent: CodeActAgent): def test_function_calling_observation_message(agent: CodeActAgent): - agent.config.function_calling = True mock_response = { 'id': 'mock_id', 'total_calls_in_response': 1, diff --git a/tests/unit/test_condenser.py b/tests/unit/test_condenser.py index 4aa5afcf7543..91878c86baa1 100644 --- a/tests/unit/test_condenser.py +++ b/tests/unit/test_condenser.py @@ -226,7 +226,7 @@ def test_llm_condenser_from_config(): assert isinstance(condenser, LLMSummarizingCondenser) assert condenser.llm.config.model == 'gpt-4o' - assert condenser.llm.config.api_key == 'test_key' + assert condenser.llm.config.api_key.get_secret_value() == 'test_key' def test_llm_condenser(mock_llm, mock_state): @@ -381,7 +381,7 @@ def test_llm_attention_condenser_from_config(): assert isinstance(condenser, LLMAttentionCondenser) assert condenser.llm.config.model == 'gpt-4o' - assert condenser.llm.config.api_key == 'test_key' + assert condenser.llm.config.api_key.get_secret_value() == 'test_key' assert condenser.max_size == 50 assert condenser.keep_first == 10 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 44a76145cf6e..5edfd64cda90 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -63,7 +63,7 @@ def test_compat_env_to_config(monkeypatch, setup_env): assert config.workspace_base == '/repos/openhands/workspace' assert isinstance(config.get_llm_config(), LLMConfig) - assert config.get_llm_config().api_key == 'sk-proj-rgMV0...' + assert config.get_llm_config().api_key.get_secret_value() == 'sk-proj-rgMV0...' assert config.get_llm_config().model == 'gpt-4o' assert isinstance(config.get_agent_config(), AgentConfig) assert isinstance(config.get_agent_config().memory_max_threads, int) @@ -83,7 +83,7 @@ def test_load_from_old_style_env(monkeypatch, default_config): load_from_env(default_config, os.environ) - assert default_config.get_llm_config().api_key == 'test-api-key' + assert default_config.get_llm_config().api_key.get_secret_value() == 'test-api-key' assert default_config.get_agent_config().memory_enabled is True assert default_config.default_agent == 'BrowsingAgent' assert default_config.workspace_base == '/opt/files/workspace' @@ -126,7 +126,7 @@ def test_load_from_new_style_toml(default_config, temp_toml_file): # default llm & agent configs assert default_config.default_agent == 'TestAgent' assert default_config.get_llm_config().model == 'test-model' - assert default_config.get_llm_config().api_key == 'toml-api-key' + assert default_config.get_llm_config().api_key.get_secret_value() == 'toml-api-key' assert default_config.get_agent_config().memory_enabled is True # undefined agent config inherits default ones @@ -291,7 +291,7 @@ def test_env_overrides_compat_toml(monkeypatch, default_config, temp_toml_file): assert default_config.get_llm_config().model == 'test-model' assert default_config.get_llm_config('llm').model == 'test-model' assert default_config.get_llm_config_from_agent().model == 'test-model' - assert default_config.get_llm_config().api_key == 'env-api-key' + assert default_config.get_llm_config().api_key.get_secret_value() == 'env-api-key' # after we set workspace_base to 'UNDEFINED' in the environment, # workspace_base should be set to that @@ -336,7 +336,7 @@ def test_env_overrides_sandbox_toml(monkeypatch, default_config, temp_toml_file) assert default_config.workspace_mount_path is None # before load_from_env, values are set to the values from the toml file - assert default_config.get_llm_config().api_key == 'toml-api-key' + assert default_config.get_llm_config().api_key.get_secret_value() == 'toml-api-key' assert default_config.sandbox.timeout == 500 assert default_config.sandbox.user_id == 1001 @@ -345,7 +345,7 @@ def test_env_overrides_sandbox_toml(monkeypatch, default_config, temp_toml_file) # values from env override values from toml assert os.environ.get('LLM_MODEL') is None assert default_config.get_llm_config().model == 'test-model' - assert default_config.get_llm_config().api_key == 'env-api-key' + assert default_config.get_llm_config().api_key.get_secret_value() == 'env-api-key' assert default_config.sandbox.timeout == 1000 assert default_config.sandbox.user_id == 1002 @@ -412,7 +412,7 @@ def test_security_config_from_dict(): # Test with all fields config_dict = {'confirmation_mode': True, 'security_analyzer': 'some_analyzer'} - security_config = SecurityConfig.from_dict(config_dict) + security_config = SecurityConfig(**config_dict) # Verify all fields are correctly set assert security_config.confirmation_mode is True @@ -560,10 +560,7 @@ def test_load_from_toml_partial_invalid(default_config, temp_toml_file, caplog): assert 'Cannot parse [llm] config from toml' in log_content assert 'values have not been applied' in log_content # Error: LLMConfig.__init__() got an unexpected keyword argume - assert ( - 'Error: LLMConfig.__init__() got an unexpected keyword argume' - in log_content - ) + assert 'Error: 1 validation error for LLMConfig' in log_content assert 'invalid_field' in log_content # invalid [sandbox] config @@ -635,12 +632,14 @@ def test_api_keys_repr_str(): aws_access_key_id='my_access_key', aws_secret_access_key='my_secret_key', ) - assert "api_key='******'" in repr(llm_config) - assert "aws_access_key_id='******'" in repr(llm_config) - assert "aws_secret_access_key='******'" in repr(llm_config) - assert "api_key='******'" in str(llm_config) - assert "aws_access_key_id='******'" in str(llm_config) - assert "aws_secret_access_key='******'" in str(llm_config) + + # Check that no secret keys are emitted in representations of the config object + assert 'my_api_key' not in repr(llm_config) + assert 'my_api_key' not in str(llm_config) + assert 'my_access_key' not in repr(llm_config) + assert 'my_access_key' not in str(llm_config) + assert 'my_secret_key' not in repr(llm_config) + assert 'my_secret_key' not in str(llm_config) # Check that no other attrs in LLMConfig have 'key' or 'token' in their name # This will fail when new attrs are added, and attract attention @@ -652,7 +651,7 @@ def test_api_keys_repr_str(): 'output_cost_per_token', 'custom_tokenizer', ] - for attr_name in dir(LLMConfig): + for attr_name in LLMConfig.model_fields.keys(): if ( not attr_name.startswith('__') and attr_name not in known_key_token_attrs_llm @@ -667,7 +666,7 @@ def test_api_keys_repr_str(): # Test AgentConfig # No attrs in AgentConfig have 'key' or 'token' in their name agent_config = AgentConfig(memory_enabled=True, memory_max_threads=4) - for attr_name in dir(AgentConfig): + for attr_name in AgentConfig.model_fields.keys(): if not attr_name.startswith('__'): assert ( 'key' not in attr_name.lower() @@ -686,16 +685,16 @@ def test_api_keys_repr_str(): modal_api_token_secret='my_modal_api_token_secret', runloop_api_key='my_runloop_api_key', ) - assert "e2b_api_key='******'" in repr(app_config) - assert "e2b_api_key='******'" in str(app_config) - assert "jwt_secret='******'" in repr(app_config) - assert "jwt_secret='******'" in str(app_config) - assert "modal_api_token_id='******'" in repr(app_config) - assert "modal_api_token_id='******'" in str(app_config) - assert "modal_api_token_secret='******'" in repr(app_config) - assert "modal_api_token_secret='******'" in str(app_config) - assert "runloop_api_key='******'" in repr(app_config) - assert "runloop_api_key='******'" in str(app_config) + assert 'my_e2b_api_key' not in repr(app_config) + assert 'my_e2b_api_key' not in str(app_config) + assert 'my_jwt_secret' not in repr(app_config) + assert 'my_jwt_secret' not in str(app_config) + assert 'my_modal_api_token_id' not in repr(app_config) + assert 'my_modal_api_token_id' not in str(app_config) + assert 'my_modal_api_token_secret' not in repr(app_config) + assert 'my_modal_api_token_secret' not in str(app_config) + assert 'my_runloop_api_key' not in repr(app_config) + assert 'my_runloop_api_key' not in str(app_config) # Check that no other attrs in AppConfig have 'key' or 'token' in their name # This will fail when new attrs are added, and attract attention @@ -705,7 +704,7 @@ def test_api_keys_repr_str(): 'modal_api_token_secret', 'runloop_api_key', ] - for attr_name in dir(AppConfig): + for attr_name in AppConfig.model_fields.keys(): if ( not attr_name.startswith('__') and attr_name not in known_key_token_attrs_app diff --git a/tests/unit/test_llm.py b/tests/unit/test_llm.py index edf82d8aa41b..227b0006b020 100644 --- a/tests/unit/test_llm.py +++ b/tests/unit/test_llm.py @@ -40,7 +40,7 @@ def default_config(): def test_llm_init_with_default_config(default_config): llm = LLM(default_config) assert llm.config.model == 'gpt-4o' - assert llm.config.api_key == 'test_key' + assert llm.config.api_key.get_secret_value() == 'test_key' assert isinstance(llm.metrics, Metrics) assert llm.metrics.model_name == 'gpt-4o' @@ -77,7 +77,7 @@ def test_llm_init_with_custom_config(): ) llm = LLM(custom_config) assert llm.config.model == 'custom-model' - assert llm.config.api_key == 'custom_key' + assert llm.config.api_key.get_secret_value() == 'custom_key' assert llm.config.max_input_tokens == 5000 assert llm.config.max_output_tokens == 1500 assert llm.config.temperature == 0.8