Compare commits

..

1 Commits

Author SHA1 Message Date
Ray Myers 2cea1461fb Version bumps 2025-06-04 18:26:28 -05:00
59 changed files with 448 additions and 1564 deletions
+1
View File
@@ -166,6 +166,7 @@ cython_debug/
# https://stackoverflow.com/questions/32964920/should-i-commit-the-vscode-folder-to-source-control
.vscode/**/*
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
!.vscode/tasks.json
+1 -2
View File
@@ -37,8 +37,7 @@ repos:
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, lxml]
# To see gaps add `--html-report mypy-report/`
[types-requests, types-setuptools, types-pyyaml, types-toml]
entry: mypy --config-file dev_config/python/mypy.ini openhands/
always_run: true
pass_filenames: false
+80 -81
View File
@@ -20,7 +20,7 @@
"navigation": {
"tabs": [
{
"tab": "Docs",
"tab": "Getting started",
"pages": [
"index",
"usage/installation",
@@ -31,7 +31,7 @@
"pages": [
"usage/cloud/openhands-cloud",
{
"group": "Integrations",
"group": "Installation",
"pages": [
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation"
@@ -43,105 +43,104 @@
]
},
{
"group": "Running OpenHands Locally",
"group": "Usage Methods",
"pages": [
"usage/local-setup",
"usage/how-to/gui-mode",
"usage/how-to/cli-mode",
"usage/how-to/headless-mode",
"usage/how-to/github-action"
]
},
}
]
},
{
"tab": "Prompting and Customization",
"pages": [
"usage/prompting/prompting-best-practices",
"usage/prompting/repository",
{
"group": "Customization",
"group": "Microagents",
"pages": [
"usage/prompting/prompting-best-practices",
"usage/prompting/repository",
"usage/prompting/microagents-overview",
"usage/prompting/microagents-repo",
"usage/prompting/microagents-keyword",
"usage/prompting/microagents-org",
"usage/prompting/microagents-public"
]
}
]
},
{
"tab": "Advanced Configuration",
"pages": [
{
"group": "LLM Configuration",
"pages": [
"usage/llms/llms",
{
"group": "Microagents",
"group": "Providers",
"pages": [
"usage/prompting/microagents-overview",
"usage/prompting/microagents-repo",
"usage/prompting/microagents-keyword",
"usage/prompting/microagents-org",
"usage/prompting/microagents-public"
"usage/llms/azure-llms",
"usage/llms/google-llms",
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/openai-llms",
"usage/llms/openrouter"
]
}
]
},
{
"group": "Advanced Configuration",
"group": "Runtime Configuration",
"pages": [
{
"group": "LLM Configuration",
"pages": [
"usage/llms/llms",
{
"group": "Providers",
"pages": [
"usage/llms/azure-llms",
"usage/llms/google-llms",
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/openai-llms",
"usage/llms/openrouter"
]
}
]
},
{
"group": "Runtime Configuration",
"pages": [
"usage/runtimes/overview",
{
"group": "Providers",
"pages": [
"usage/runtimes/docker",
"usage/runtimes/remote",
"usage/runtimes/local",
{
"group": "Third-Party Providers",
"pages": [
"usage/runtimes/modal",
"usage/runtimes/daytona",
"usage/runtimes/runloop",
"usage/runtimes/e2b"
]
}
]
}
]
},
"usage/configuration-options",
"usage/how-to/custom-sandbox-guide",
"usage/search-engine-setup",
"usage/mcp"
]
},
{
"group": "Troubleshooting & Feedback",
"pages": [
"usage/troubleshooting/troubleshooting",
"usage/feedback"
]
},
{
"group": "OpenHands Developers",
"pages": [
"usage/how-to/development-overview",
"usage/runtimes/overview",
{
"group": "Architecture",
"group": "Providers",
"pages": [
"usage/architecture/backend",
"usage/architecture/runtime"
"usage/runtimes/docker",
"usage/runtimes/remote",
"usage/runtimes/local",
{
"group": "Third-Party Providers",
"pages": [
"usage/runtimes/modal",
"usage/runtimes/daytona",
"usage/runtimes/runloop",
"usage/runtimes/e2b"
]
}
]
},
"usage/how-to/debugging",
"usage/how-to/evaluation-harness",
"usage/how-to/websocket-connection"
}
]
}
},
"usage/configuration-options",
"usage/how-to/custom-sandbox-guide",
"usage/search-engine-setup",
"usage/mcp"
]
},
{
"tab": "Troubleshooting & Feedback",
"pages": [
"usage/troubleshooting/troubleshooting",
"usage/feedback"
]
},
{
"tab": "For OpenHands Developers",
"pages": [
"usage/how-to/development-overview",
{
"group": "Architecture",
"pages": [
"usage/architecture/backend",
"usage/architecture/runtime"
]
},
"usage/how-to/debugging",
"usage/how-to/evaluation-harness",
"usage/how-to/websocket-connection"
]
},
{
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+23 -40
View File
@@ -1,22 +1,30 @@
---
title: GitHub Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitHub repositories. Once
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub issues!
title: GitHub Installation
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitHub repositories.
---
## Prerequisites
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitHub account](/usage/cloud/openhands-cloud).
- A GitHub account
- Access to OpenHands Cloud
## Adding GitHub Repository Access
## Installation Steps
You can grant OpenHands access to specific GitHub repositories:
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
2. If you haven't connected your GitHub account yet:
- Click on `Connect to GitHub`
- Review and accept the terms of service
- Authorize the OpenHands AI application
1. Click on `Add GitHub repos` on the landing page.
## Adding Repository Access
You can grant OpenHands access to specific repositories:
1. Click on `Add GitHub repos`
2. Select your organization and choose the specific repositories to grant OpenHands access to.
<Accordion title="OpenHands permissions">
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
- Actions: Read and write
- Administration: Read-only
- Commit statuses: Read and write
- Contents: Read and write
- Issues: Read and write
@@ -27,45 +35,20 @@ You can grant OpenHands access to specific GitHub repositories:
- Repository access for a user is granted based on:
- Permission granted for the repository
- User's GitHub permissions (owner/collaborator)
</Accordion>
3. Click `Install & Authorize`.
3. Click `Install & Authorize`
## Modifying Repository Access
You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
You can modify repository access at any time by visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab.
## Working With Github Repos in Openhands Cloud
## Using OpenHands with GitHub
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the session!
Once you've granted repository access, you can use OpenHands with your GitHub repositories.
![Connect Repo](/static/img/connect-repo.png)
## Working on Github Issues and Pull Requests Using Openhands
Giving GitHub repository access to OpenHands also allows you to work on GitHub issues and pull requests directly.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a pull request if it determines that the issue has been successfully resolved.
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
### Working with Pull Requests
To get OpenHands to work on pull requests, mention `@openhands` in the comments to:
- Ask questions
- Request updates
- Get code explanations
For details on how to use OpenHands with GitHub issues and pull requests, see the [Cloud Issue Resolver](./cloud-issue-resolver) documentation.
## Next Steps
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: GitLab Integration
title: GitLab Installation
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
---
+4 -3
View File
@@ -1,12 +1,13 @@
---
title: Getting Started
description: Getting started with OpenHands Cloud.
description: Getting started with OpenHands Cloud
---
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands.
## Accessing OpenHands Cloud
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
visit [app.all-hands.dev](https://app.all-hands.dev).
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
You'll be prompted to connect with your GitHub or GitLab account:
+159 -5
View File
@@ -1,6 +1,6 @@
---
title: Quick Start
description: Running OpenHands Cloud or running on your local system.
description: Running OpenHands on the cloud or your local desktop
icon: rocket
---
@@ -10,10 +10,164 @@ The easiest way to get started with OpenHands is on OpenHands Cloud, which comes
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
You'll be prompted to connect with your GitHub or GitLab account:
## Running OpenHands Locally
1. Click `Log in with GitHub` or `Log in with GitLab`.
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [running OpenHands locally.](/usage/local-setup)
Once you've connected your account, you can:
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories
- [Access the Cloud UI](/usage/cloud/cloud-ui) to interact with the web interface
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands
- [Set up the Cloud Issue Resolver](/usage/cloud/cloud-issue-resolver) to automate code fixes and provide intelligent assistance
## Running OpenHands on your local desktop
### System Requirements
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
- Linux
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
### Prerequisites
<AccordionGroup>
<Accordion title="MacOS">
**Docker Desktop**
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
</Accordion>
<Accordion title="Linux">
<Note>
Tested with Ubuntu 22.04.
</Note>
**Docker Desktop**
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
</Accordion>
<Accordion title="Windows">
**WSL**
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
**Docker Desktop**
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
2. Open Docker Desktop, go to `Settings` and confirm the following:
- General: `Use the WSL 2 based engine` is enabled.
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
<Note>
The docker command below to start the app must be run inside the WSL terminal.
</Note>
</Accordion>
</AccordionGroup>
### Start the App
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.41
```
You'll find OpenHands running at http://localhost:3000!
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes/docker#connecting-to-your-filesystem),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
or run it on tagged issues with [a GitHub action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
### Setup
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
This can be done during the initial settings popup or by selecting the `Settings`
button (gear icon) in the UI.
If the required model does not exist in the list, in `Settings` under the `LLM` tab, you can toggle `Advanced` options
and manually enter it with the correct prefix in the `Custom Model` text box.
The `Advanced` options also allow you to specify a `Base URL` if required.
#### Getting an API Key
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
<AccordionGroup>
<Accordion title="Anthropic (Claude)">
1. [Create an Anthropic account](https://console.anthropic.com/).
2. [Generate an API key](https://console.anthropic.com/settings/keys).
3. [Set up billing](https://console.anthropic.com/settings/billing).
Consider setting usage limits to control costs.
</Accordion>
<Accordion title="OpenAI">
1. [Create an OpenAI account](https://platform.openai.com/).
2. [Generate an API key](https://platform.openai.com/api-keys).
3. [Set up billing](https://platform.openai.com/account/billing/overview).
</Accordion>
</AccordionGroup>
#### Setting Up Search Engine
OpenHands can be configured to use a search engine to allow the agent to search the web for information when needed.
Search functionality is enabled by default in OpenHands Cloud. No additional setup is required.
To enable search functionality in self-hosted OpenHands:
1. Get a Tavily API key from [tavily.com](https://tavily.com/)
2. Enter the API key in the Settings page under `LLM` tab, `Search API Key (Tavily)`
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
Now you're ready to [get started with OpenHands](./getting-started).
#### Versions
The [docker command above](./installation#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
- For a specific release, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with the version number.
We use SemVer so `0.9` will automatically point to the latest `0.9.x` release, and `0` will point to the latest `0.x.x` release.
- For the most up-to-date development version, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with `main`.
This version is unstable and is recommended for testing or development purposes only.
For the development workflow, see [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
Are you having trouble? Check out our [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting).
-143
View File
@@ -1,143 +0,0 @@
---
title: Getting Started
description: Getting started with running OpenHands locally.
---
## Recommended Methods for Running Openhands on Your Local System
### System Requirements
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
- Linux
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
### Prerequisites
<AccordionGroup>
<Accordion title="MacOS">
**Docker Desktop**
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
</Accordion>
<Accordion title="Linux">
<Note>
Tested with Ubuntu 22.04.
</Note>
**Docker Desktop**
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
</Accordion>
<Accordion title="Windows">
**WSL**
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
**Docker Desktop**
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
2. Open Docker Desktop, go to `Settings` and confirm the following:
- General: `Use the WSL 2 based engine` is enabled.
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
<Note>
The docker command below to start the app must be run inside the WSL terminal.
</Note>
</Accordion>
</AccordionGroup>
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.40
```
You'll find OpenHands running at http://localhost:3000!
### Setup
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
This can be done during the initial settings popup or by selecting the `Settings`
button (gear icon) in the UI.
If the required model does not exist in the list, in `Settings` under the `LLM` tab, you can toggle `Advanced` options
and manually enter it with the correct prefix in the `Custom Model` text box.
The `Advanced` options also allow you to specify a `Base URL` if required.
#### Getting an API Key
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
<AccordionGroup>
<Accordion title="Anthropic (Claude)">
1. [Create an Anthropic account](https://console.anthropic.com/).
2. [Generate an API key](https://console.anthropic.com/settings/keys).
3. [Set up billing](https://console.anthropic.com/settings/billing).
</Accordion>
<Accordion title="OpenAI">
1. [Create an OpenAI account](https://platform.openai.com/).
2. [Generate an API key](https://platform.openai.com/api-keys).
3. [Set up billing](https://platform.openai.com/account/billing/overview).
</Accordion>
</AccordionGroup>
Consider setting usage limits to control costs.
#### Setting Up Search Engine
OpenHands can be configured to use a search engine to allow the agent to search the web for information when needed.
To enable search functionality in OpenHands:
1. Get a Tavily API key from [tavily.com](https://tavily.com/).
2. Enter the Tavily API key in the Settings page under `LLM` tab > `Search API Key (Tavily)`
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
Now you're ready to [get started with OpenHands](/usage/getting-started).
### Versions
The [docker command above](/usage/local-setup#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
- For a specific release, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with the version number.
For example, `0.9` will automatically point to the latest `0.9.x` release, and `0` will point to the latest `0.x.x` release.
- For the most up-to-date development version, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with `main`.
This version is unstable and is recommended for testing or development purposes only.
## Next Steps
- [Connect OpenHands to your local filesystem.](/usage/runtimes/docker#connecting-to-your-filesystem) to use OpenHands with your GitHub repositories
- [Run OpenHands in a scriptable headless mode.](/usage/how-to/headless-mode)
- [Run OpenHands with a friendly CLI.](/usage/how-to/cli-mode)
- [Run OpenHands on tagged issues with a GitHub action.](/usage/how-to/github-action)
@@ -6,21 +6,6 @@ import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as useSettingsModule from "#/hooks/query/use-settings";
// Mock the useSettings hook
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
return {
...actual,
useSettings: vi.fn().mockReturnValue({
data: {
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
},
isLoading: false,
}),
};
});
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -35,7 +20,6 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -63,10 +47,6 @@ describe("Settings Billing", () => {
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
],
},
]);
@@ -47,7 +47,6 @@ const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
// Attributes that typically don't contain user-facing text
const NON_TEXT_ATTRIBUTES = [
"allow",
"className",
"i18nKey",
"testId",
@@ -70,7 +69,6 @@ const NON_TEXT_ATTRIBUTES = [
"aria-describedby",
"aria-hidden",
"role",
"sandbox",
];
function shouldIgnorePath(filePath) {
@@ -116,7 +114,6 @@ const EXCLUDED_TECHNICAL_STRINGS = [
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
"search-api-key-input", // Input name for search API key
"noopener,noreferrer", // Options for window.open
];
function isExcludedTechnicalString(str) {
@@ -1,32 +0,0 @@
import React from "react";
import { useLocation, useNavigate } from "react-router";
import { useSettings } from "#/hooks/query/use-settings";
/**
* A component that restricts access to routes based on email verification status.
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
*/
export function EmailVerificationGuard({
children,
}: {
children: React.ReactNode;
}) {
const { data: settings, isLoading } = useSettings();
const navigate = useNavigate();
const { pathname } = useLocation();
React.useEffect(() => {
// If settings are still loading, don't do anything yet
if (isLoading) return;
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
if (settings?.EMAIL_VERIFIED === false) {
// Allow access to /settings/user but redirect from any other page
if (pathname !== "/settings/user") {
navigate("/settings/user", { replace: true });
}
}
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
return children;
}
@@ -69,21 +69,16 @@ export function Sidebar() {
<div className="flex items-center justify-center">
<AllHandsLogoButton />
</div>
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
<NewProjectButton />
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
onClick={() =>
settings?.EMAIL_VERIFIED === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
disabled={settings?.EMAIL_VERIFIED === false}
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
<DocsButton />
<SettingsButton />
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
@@ -8,13 +8,11 @@ import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}
export function ConversationPanelButton({
isOpen,
onClick,
disabled = false,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
@@ -24,14 +22,10 @@ export function ConversationPanelButton({
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
disabled={disabled}
>
<FaListUl
size={22}
className={cn(
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
/>
</TooltipButton>
);
@@ -3,24 +3,15 @@ import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
interface DocsButtonProps {
disabled?: boolean;
}
export function DocsButton({ disabled = false }: DocsButtonProps) {
export function DocsButton() {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.SIDEBAR$DOCS)}
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
href="https://docs.all-hands.dev"
disabled={disabled}
>
<DocsIcon
width={28}
height={28}
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
/>
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
</TooltipButton>
);
}
@@ -3,11 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
interface NewProjectButtonProps {
disabled?: boolean;
}
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
export function NewProjectButton() {
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
@@ -16,7 +12,6 @@ export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
ariaLabel={startNewProject}
navLinkTo="/"
testId="new-project-button"
disabled={disabled}
>
<PlusIcon width={28} height={28} />
</TooltipButton>
@@ -5,13 +5,9 @@ import { I18nKey } from "#/i18n/declaration";
interface SettingsButtonProps {
onClick?: () => void;
disabled?: boolean;
}
export function SettingsButton({
onClick,
disabled = false,
}: SettingsButtonProps) {
export function SettingsButton({ onClick }: SettingsButtonProps) {
const { t } = useTranslation();
return (
@@ -21,7 +17,6 @@ export function SettingsButton({
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
</TooltipButton>
@@ -12,7 +12,6 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
disabled?: boolean;
}
export function TooltipButton({
@@ -24,10 +23,9 @@ export function TooltipButton({
ariaLabel,
testId,
className,
disabled = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
if (onClick) {
onClick();
e.preventDefault();
}
@@ -39,12 +37,7 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
className={cn("hover:opacity-80", className)}
>
{children}
</button>
@@ -52,7 +45,7 @@ export function TooltipButton({
let content;
if (navLinkTo && !disabled) {
if (navLinkTo) {
content = (
<NavLink
to={navLinkTo}
@@ -70,24 +63,7 @@ export function TooltipButton({
{children}
</NavLink>
);
} else if (navLinkTo && disabled) {
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"text-[#9099AC]",
"opacity-50 cursor-not-allowed",
className,
)}
disabled
>
{children}
</button>
);
} else if (href && !disabled) {
} else if (href) {
content = (
<a
href={href}
@@ -100,19 +76,6 @@ export function TooltipButton({
{children}
</a>
);
} else if (href && disabled) {
// If disabled and has href, render a button that looks like a link but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn("opacity-50 cursor-not-allowed", className)}
disabled
>
{children}
</button>
);
} else {
content = buttonContent;
}
@@ -1,22 +1,14 @@
import { useEffect } from "react";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserConversation } from "./use-user-conversation";
import OpenHands from "#/api/open-hands";
const FIVE_MINUTES = 1000 * 60 * 5;
export const useActiveConversation = () => {
const { conversationId } = useConversationId();
const userConversation = useUserConversation(conversationId, (query) => {
return useUserConversation(conversationId, (query) => {
if (query.state.data?.status === "STARTING") {
return 2000; // 2 seconds
}
return FIVE_MINUTES;
});
useEffect(() => {
const conversation = userConversation.data;
OpenHands.setCurrentConversation(conversation || null);
}, [conversationId, userConversation.isFetched]);
return userConversation;
};
+1 -2
View File
@@ -27,8 +27,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
IS_NEW_USER: false,
};
@@ -23,6 +23,7 @@ export const useUserConversation = (
queryKey: ["user", "conversation", cid],
queryFn: async () => {
const conversation = await OpenHands.getConversation(cid!);
OpenHands.setCurrentConversation(conversation);
return conversation;
},
enabled: !!cid,
@@ -1,116 +0,0 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { AxiosError } from "axios";
import { openHands } from "#/api/open-hands-axios";
import { Settings } from "#/types/settings";
import { useConfig } from "#/hooks/query/use-config";
/**
* Hook to handle email verification errors (403 with "Email has not been verified" message)
* This hook sets up an axios interceptor that will reload settings and navigate to the user settings page
* when a 403 error with the specific message is encountered.
*/
export const useHandleEmailVerification = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: config } = useConfig();
const appMode = config?.APP_MODE;
console.log(`config: ${config}`);
console.log(`AppMode: ${appMode}`);
useEffect(() => {
// Add response interceptor
const interceptorId = openHands.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
console.log(
`Received error ${error.response?.status} with message ${error.response?.data}`,
);
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
// check for email verification error message no matter how it is returned.
const isEmailNotVerified = (() => {
const data = error.response?.data;
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) =>
typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" &&
value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) =>
typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
})();
// Check if it's a 403 error with the specific message
if (error.response?.status === 403 && isEmailNotVerified) {
console.log("EMAIL VERIFICATION ERROR");
// Only handle this in SAAS mode
console.log(`config1: ${config}`);
console.log(`AppMode1: ${appMode}`);
if (appMode === "saas") {
// Update settings to mark email as unverified
queryClient.setQueryData(
["settings"],
(oldData: Settings | undefined) => {
if (oldData) {
console.log("ADDING EMAIL_VERIFIED is FALSE");
return {
...oldData,
EMAIL_VERIFIED: false,
};
}
console.log("NO CHANGES TO SETTINGS");
return oldData;
},
);
// Invalidate settings to reload them
queryClient.invalidateQueries({ queryKey: ["settings"] });
// Navigate to settings/user page
// The EmailVerificationGuard will handle the redirect
console.log("NAVIGATING to /settings/user");
navigate("/settings/user");
}
} else {
console.log("NOT EMAIL VERIFICATION ERROR");
console.log(typeof error.response?.data);
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);
// Clean up interceptor when component unmounts
return () => {
openHands.interceptors.response.eject(interceptorId);
};
}, [queryClient, navigate]);
};
+1 -18
View File
@@ -138,9 +138,7 @@ export enum I18nKey {
VSCODE$LOADING = "VSCODE$LOADING",
VSCODE$URL_NOT_AVAILABLE = "VSCODE$URL_NOT_AVAILABLE",
VSCODE$FETCH_ERROR = "VSCODE$FETCH_ERROR",
VSCODE$CROSS_ORIGIN_WARNING = "VSCODE$CROSS_ORIGIN_WARNING",
VSCODE$URL_PARSE_ERROR = "VSCODE$URL_PARSE_ERROR",
VSCODE$OPEN_IN_NEW_TAB = "VSCODE$OPEN_IN_NEW_TAB",
VSCODE$IFRAME_PERMISSIONS = "VSCODE$IFRAME_PERMISSIONS",
INCREASE_TEST_COVERAGE = "INCREASE_TEST_COVERAGE",
AUTO_MERGE_PRS = "AUTO_MERGE_PRS",
FIX_README = "FIX_README",
@@ -334,7 +332,6 @@ export enum I18nKey {
SETTINGS$CONFIRMATION_MODE_TOOLTIP = "SETTINGS$CONFIRMATION_MODE_TOOLTIP",
SETTINGS$AGENT_SELECT_ENABLED = "SETTINGS$AGENT_SELECT_ENABLED",
SETTINGS$SECURITY_ANALYZER = "SETTINGS$SECURITY_ANALYZER",
SETTINGS$SECURITY_ANALYZER_PLACEHOLDER = "SETTINGS$SECURITY_ANALYZER_PLACEHOLDER",
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
SETTINGS$SAVED = "SETTINGS$SAVED",
@@ -555,18 +552,4 @@ export enum I18nKey {
TIPS$PROTIP = "TIPS$PROTIP",
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
SETTINGS$SAVE = "SETTINGS$SAVE",
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
SETTINGS$SENDING = "SETTINGS$SENDING",
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
}
+15 -287
View File
@@ -2207,53 +2207,21 @@
"tr": "VS Code URL'si alınamadı",
"uk": "Не вдалося отримати VS Code URL"
},
"VSCODE$CROSS_ORIGIN_WARNING": {
"en": "The code editor cannot be embedded due to browser security restrictions. Cross-origin cookies are being blocked.",
"ja": "ブラウザのセキュリティ制限により、コードエディタを埋め込むことができません。クロスオリジンCookieがブロックされています。",
"zh-CN": "由于浏览器安全限制,无法嵌入代码编辑器。跨源Cookie被阻止。",
"zh-TW": "由於瀏覽器安全限制,無法嵌入代碼編輯器。跨源Cookie被阻止。",
"ko-KR": "브라우저 보안 제한으로 인해 코드 편집기를 삽입할 수 없습니다. 교차 출처 쿠키가 차단되고 있습니다.",
"de": "Der Code-Editor kann aufgrund von Browser-Sicherheitsbeschränkungen nicht eingebettet werden. Cross-Origin-Cookies werden blockiert.",
"no": "Koderedigereren kan ikke bygges inn på grunn av nettleserens sikkerhetsbegrensninger. Cross-origin cookies blir blokkert.",
"it": "L'editor di codice non può essere incorporato a causa delle restrizioni di sicurezza del browser. I cookie cross-origin vengono bloccati.",
"pt": "O editor de código não pode ser incorporado devido a restrições de segurança do navegador. Cookies de origem cruzada estão sendo bloqueados.",
"es": "El editor de código no se puede incrustar debido a las restricciones de seguridad del navegador. Las cookies de origen cruzado están siendo bloqueadas.",
"ar": "لا يمكن تضمين محرر التعليمات البرمجية بسبب قيود أمان المتصفح. يتم حظر ملفات تعريف الارتباط عبر المصدر.",
"fr": "L'éditeur de code ne peut pas être intégré en raison des restrictions de sécurité du navigateur. Les cookies cross-origin sont bloqués.",
"tr": "Tarayıcı güvenlik kısıtlamaları nedeniyle kod düzenleyici yerleştirilemiyor. Çapraz kaynaklı çerezler engelleniyor.",
"uk": "Редактор коду не може бути вбудований через обмеження безпеки браузера. Блокуються файли cookie з різних джерел."
},
"VSCODE$URL_PARSE_ERROR": {
"en": "Error parsing URL",
"ja": "URLの解析エラー",
"zh-CN": "URL解析错误",
"zh-TW": "URL解析錯誤",
"ko-KR": "URL 구문 분석 오류",
"de": "Fehler beim Parsen der URL",
"no": "Feil ved parsing av URL",
"it": "Errore durante l'analisi dell'URL",
"pt": "Erro ao analisar URL",
"es": "Error al analizar URL",
"ar": "خطأ في تحليل عنوان URL",
"fr": "Erreur d'analyse de l'URL",
"tr": "URL ayrıştırma hatası",
"uk": "Помилка аналізу URL"
},
"VSCODE$OPEN_IN_NEW_TAB": {
"en": "Open in New Tab",
"ja": "新しいタブで開く",
"zh-CN": "在新标签页中打开",
"zh-TW": "在新標籤頁中打開",
"ko-KR": "새 탭에서 열기",
"de": "In neuem Tab öffnen",
"no": "Åpne i ny fane",
"it": "Apri in una nuova scheda",
"pt": "Abrir em nova aba",
"es": "Abrir en nueva pestaña",
"ar": "فتح في علامة تبويب جديدة",
"fr": "Ouvrir dans un nouvel onglet",
"tr": "Yeni Sekmede Aç",
"uk": "Відкрити в новій вкладці"
"VSCODE$IFRAME_PERMISSIONS": {
"en": "clipboard-read; clipboard-write",
"ja": "clipboard-read; clipboard-write",
"zh-CN": "clipboard-read; clipboard-write",
"zh-TW": "clipboard-read; clipboard-write",
"ko-KR": "clipboard-read; clipboard-write",
"de": "clipboard-read; clipboard-write",
"no": "clipboard-read; clipboard-write",
"it": "clipboard-read; clipboard-write",
"pt": "clipboard-read; clipboard-write",
"es": "clipboard-read; clipboard-write",
"ar": "clipboard-read; clipboard-write",
"fr": "clipboard-read; clipboard-write",
"tr": "clipboard-read; clipboard-write",
"uk": "clipboard-read; clipboard-write"
},
"INCREASE_TEST_COVERAGE": {
"en": "Increase test coverage",
@@ -5343,22 +5311,6 @@
"ja": "セキュリティアナライザー",
"uk": "Увімкнути аналізатор безпеки"
},
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER":{
"en": "Select a security analyzer…",
"de": "Wählen Sie einen Sicherheitsanalysator aus…",
"zh-CN": "选择一个安全分析器…",
"zh-TW": "選擇一個安全分析器…",
"ko-KR": "보안 분석기를 선택하세요…",
"no": "Velg en sikkerhetsanalysator…",
"it": "Seleziona un analizzatore di sicurezza…",
"pt": "Selecione um analisador de segurança…",
"es": "Seleccione un analizador de seguridad…",
"ar": "اختر محلل الأمان…",
"fr": "Sélectionnez un analyseur de sécurité…",
"tr": "Bir güvenlik analizörü seçin…",
"ja": "セキュリティアナライザーを選択…",
"uk": "Виберіть аналізатор безпеки…"
},
"SETTINGS$DONT_KNOW_API_KEY": {
"en": "Don't know your API key?",
"ja": "APIキーがわかりませんか?",
@@ -8878,229 +8830,5 @@
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
"de": "Feedback senden, bitte warten...",
"uk": "Відправляємо відгук, будь ласка, почекайте..."
},
"SETTINGS$NAV_USER": {
"en": "User",
"ja": "ユーザー",
"zh-CN": "用户",
"zh-TW": "用戶",
"ko-KR": "사용자",
"no": "Bruker",
"it": "Utente",
"pt": "Usuário",
"es": "Usuario",
"ar": "المستخدم",
"fr": "Utilisateur",
"tr": "Kullanıcı",
"de": "Benutzer",
"uk": "Користувач"
},
"SETTINGS$USER_TITLE": {
"en": "User Information",
"ja": "ユーザー情報",
"zh-CN": "用户信息",
"zh-TW": "用戶信息",
"ko-KR": "사용자 정보",
"no": "Brukerinformasjon",
"it": "Informazioni utente",
"pt": "Informações do usuário",
"es": "Información del usuario",
"ar": "معلومات المستخدم",
"fr": "Informations utilisateur",
"tr": "Kullanıcı Bilgileri",
"de": "Benutzerinformationen",
"uk": "Інформація про користувача"
},
"SETTINGS$USER_EMAIL": {
"en": "Email",
"ja": "メール",
"zh-CN": "邮箱",
"zh-TW": "郵箱",
"ko-KR": "이메일",
"no": "E-post",
"it": "Email",
"pt": "Email",
"es": "Correo electrónico",
"ar": "البريد الإلكتروني",
"fr": "Email",
"tr": "E-posta",
"de": "E-Mail",
"uk": "Електронна пошта"
},
"SETTINGS$USER_EMAIL_LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "加載中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen...",
"uk": "Завантаження..."
},
"SETTINGS$SAVE": {
"en": "Save",
"ja": "保存",
"zh-CN": "保存",
"zh-TW": "儲存",
"ko-KR": "저장",
"no": "Lagre",
"it": "Salva",
"pt": "Salvar",
"es": "Guardar",
"ar": "حفظ",
"fr": "Enregistrer",
"tr": "Kaydet",
"de": "Speichern",
"uk": "Зберегти"
},
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
"en": "Email saved successfully",
"ja": "メールが正常に保存されました",
"zh-CN": "邮箱保存成功",
"zh-TW": "郵箱儲存成功",
"ko-KR": "이메일이 성공적으로 저장되었습니다",
"no": "E-post lagret",
"it": "Email salvata con successo",
"pt": "Email salvo com sucesso",
"es": "Correo electrónico guardado con éxito",
"ar": "تم حفظ البريد الإلكتروني بنجاح",
"fr": "Email enregistré avec succès",
"tr": "E-posta başarıyla kaydedildi",
"de": "E-Mail erfolgreich gespeichert",
"uk": "Електронну пошту успішно збережено"
},
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
"en": "Your email has been verified successfully!",
"ja": "メールアドレスの確認が完了しました!",
"zh-CN": "您的邮箱已成功验证!",
"zh-TW": "您的郵箱已成功驗證!",
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
"no": "E-posten din er bekreftet!",
"it": "La tua email è stata verificata con successo!",
"pt": "Seu email foi verificado com sucesso!",
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
"fr": "Votre email a été vérifié avec succès !",
"tr": "E-postanız başarıyla doğrulandı!",
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
"uk": "Вашу електронну пошту успішно підтверджено!"
},
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
"en": "Failed to save email",
"ja": "メールの保存に失敗しました",
"zh-CN": "保存邮箱失败",
"zh-TW": "儲存郵箱失敗",
"ko-KR": "이메일 저장 실패",
"no": "Kunne ikke lagre e-post",
"it": "Impossibile salvare l'email",
"pt": "Falha ao salvar email",
"es": "Error al guardar el correo electrónico",
"ar": "فشل في حفظ البريد الإلكتروني",
"fr": "Échec de l'enregistrement de l'email",
"tr": "E-posta kaydedilemedi",
"de": "E-Mail konnte nicht gespeichert werden",
"uk": "Не вдалося зберегти електронну пошту"
},
"SETTINGS$SENDING": {
"en": "Sending",
"ja": "送信中",
"zh-CN": "发送中",
"zh-TW": "發送中",
"ko-KR": "전송 중",
"no": "Sender",
"it": "Invio in corso",
"pt": "Enviando",
"es": "Enviando",
"ar": "جاري الإرسال",
"fr": "Envoi en cours",
"tr": "Gönderiliyor",
"de": "Wird gesendet",
"uk": "Надсилання"
},
"SETTINGS$VERIFICATION_EMAIL_SENT": {
"en": "Verification email sent",
"ja": "確認メールを送信しました",
"zh-CN": "验证邮件已发送",
"zh-TW": "驗證郵件已發送",
"ko-KR": "인증 이메일이 전송되었습니다",
"no": "Bekreftelsese-post sendt",
"it": "Email di verifica inviata",
"pt": "Email de verificação enviado",
"es": "Correo de verificación enviado",
"ar": "تم إرسال بريد التحقق",
"fr": "Email de vérification envoyé",
"tr": "Doğrulama e-postası gönderildi",
"de": "Bestätigungs-E-Mail gesendet",
"uk": "Лист підтвердження надіслано"
},
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
"en": "You must verify your email address before using All Hands",
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
"zh-CN": "使用All Hands前,您必须验证您的电子邮件地址",
"zh-TW": "使用All Hands前,您必須驗證您的電子郵件地址",
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
},
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
"en": "Your access is limited until your email is verified. You can only access this settings page.",
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
},
"SETTINGS$RESEND_VERIFICATION": {
"en": "Resend verification",
"ja": "確認メールを再送信",
"zh-CN": "重新发送验证",
"zh-TW": "重新發送驗證",
"ko-KR": "인증 재전송",
"no": "Send bekreftelse på nytt",
"it": "Rinvia verifica",
"pt": "Reenviar verificação",
"es": "Reenviar verificación",
"ar": "إعادة إرسال التحقق",
"fr": "Renvoyer la vérification",
"tr": "Doğrulamayı yeniden gönder",
"de": "Bestätigung erneut senden",
"uk": "Надіслати підтвердження повторно"
},
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
"en": "Failed to resend verification email",
"ja": "確認メールの再送信に失敗しました",
"zh-CN": "重新发送验证邮件失败",
"zh-TW": "重新發送驗證郵件失敗",
"ko-KR": "인증 이메일 재전송 실패",
"no": "Kunne ikke sende bekreftelsese-post på nytt",
"it": "Impossibile rinviare l'email di verifica",
"pt": "Falha ao reenviar email de verificação",
"es": "Error al reenviar el correo de verificación",
"ar": "فشل في إعادة إرسال بريد التحقق",
"fr": "Échec du renvoi de l'email de vérification",
"tr": "Doğrulama e-postası yeniden gönderilemedi",
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
"uk": "Не вдалося повторно надіслати лист підтвердження"
}
}
-1
View File
@@ -12,7 +12,6 @@ export default [
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("user", "routes/user-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
-3
View File
@@ -469,9 +469,6 @@ function LlmSettingsScreen() {
label: analyzer,
})) || []
}
placeholder={t(
I18nKey.SETTINGS$SECURITY_ANALYZER_PLACEHOLDER,
)}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
+1 -8
View File
@@ -24,9 +24,7 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useHandleEmailVerification } from "#/hooks/use-handle-email-verification";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
export function ErrorBoundary() {
const error = useRouteError();
@@ -94,9 +92,6 @@ export default function MainApp() {
// Handle authentication callback and set login method after successful authentication
useAuthCallback();
// Set up interceptor for email verification errors
useHandleEmailVerification();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
@@ -209,9 +204,7 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
<Outlet />
</div>
{renderAuthModal && (
+1 -3
View File
@@ -15,7 +15,6 @@ function SettingsScreen() {
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
@@ -34,11 +33,10 @@ function SettingsScreen() {
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/user");
navigate("/settings/git");
}
} else {
const noEnteringPaths = [
"/settings/user",
"/settings/billing",
"/settings/credits",
"/settings/api-keys",
-206
View File
@@ -1,206 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
function EmailInputSection({
email,
onEmailChange,
onSaveEmail,
onResendVerification,
isSaving,
isResendingVerification,
isEmailChanged,
emailVerified,
children,
}: {
email: string;
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSaveEmail: () => void;
onResendVerification: () => void;
isSaving: boolean;
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
<div className="flex items-center gap-3">
<input
type="email"
value={email}
onChange={onEmailChange}
className="text-base text-white p-2 bg-base-tertiary rounded border border-tertiary flex-grow focus:outline-none focus:border-transparent focus:ring-0"
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="save-email-button"
>
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
</button>
{emailVerified === false && (
<button
type="button"
onClick={onResendVerification}
disabled={isResendingVerification}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="resend-verification-button"
>
{isResendingVerification
? t("SETTINGS$SENDING")
: t("SETTINGS$RESEND_VERIFICATION")}
</button>
)}
</div>
{children}
</div>
</div>
);
}
function VerificationAlert() {
const { t } = useTranslation();
return (
<div
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
role="alert"
>
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
<p className="text-sm">
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
</p>
</div>
);
}
// These components have been replaced with toast notifications
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading, refetch } = useSettings();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
}
}, [settings?.EMAIL]);
useEffect(() => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (
prevVerificationStatusRef.current === false &&
settings?.EMAIL_VERIFIED === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
}, 2000);
}
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
if (settings?.EMAIL_VERIFIED === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
}
return () => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handleSaveEmail = async () => {
if (email === originalEmail) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
setOriginalEmail(email);
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
} finally {
setIsSaving(false);
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
};
const isEmailChanged = email !== originalEmail;
return (
<div data-testid="user-settings-screen" className="flex flex-col h-full">
<div className="p-9 flex flex-col gap-6">
{isLoading ? (
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
) : (
<EmailInputSection
email={email}
onEmailChange={handleEmailChange}
onSaveEmail={handleSaveEmail}
onResendVerification={handleResendVerification}
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>
</div>
);
}
export default UserSettingsScreen;
+4 -52
View File
@@ -1,11 +1,10 @@
import React, { useState, useEffect } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
function VSCodeTab() {
const { t } = useTranslation();
@@ -13,31 +12,6 @@ function VSCodeTab() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null);
useEffect(() => {
if (data?.url) {
try {
const iframeProtocol = new URL(data.url).protocol;
const currentProtocol = window.location.protocol;
// Check if the iframe URL has a different protocol than the current page
setIsCrossProtocol(
VSCODE_IN_NEW_TAB() || iframeProtocol !== currentProtocol,
);
} catch (e) {
// Silently handle URL parsing errors
setIframeError(t("VSCODE$URL_PARSE_ERROR"));
}
}
}, [data?.url]);
const handleOpenInNewTab = () => {
if (data?.url) {
window.open(data.url, "_blank", "noopener,noreferrer");
}
};
if (isRuntimeInactive) {
return (
@@ -55,36 +29,14 @@ function VSCodeTab() {
);
}
if (error || (data && data.error) || !data?.url || iframeError) {
if (error || (data && data.error) || !data?.url) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{iframeError ||
data?.error ||
String(error) ||
t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
{data?.error || String(error) || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
</div>
);
}
// If cross-origin, show a button to open in new tab
if (isCrossProtocol) {
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-xl text-tertiary-light text-center max-w-md">
{t("VSCODE$CROSS_ORIGIN_WARNING")}
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark transition-colors"
>
{t("VSCODE$OPEN_IN_NEW_TAB")}
</button>
</div>
);
}
// If same origin, use the iframe
return (
<div className="h-full w-full">
<iframe
@@ -92,7 +44,7 @@ function VSCodeTab() {
title={t(I18nKey.VSCODE$TITLE)}
src={data.url}
className="w-full h-full border-0"
allow="clipboard-read; clipboard-write"
allow={t(I18nKey.VSCODE$IFRAME_PERMISSIONS)}
/>
</div>
);
-2
View File
@@ -19,8 +19,6 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {
sse_servers: [],
stdio_servers: [],
-4
View File
@@ -45,8 +45,6 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
};
export type ApiSettings = {
@@ -70,8 +68,6 @@ export type ApiSettings = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
};
email?: string;
email_verified?: boolean;
};
export type PostSettings = Settings & {
-1
View File
@@ -14,6 +14,5 @@ export function loadFeatureFlag(
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
+1 -4
View File
@@ -73,10 +73,7 @@ class SandboxConfig(BaseModel):
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=3600,
description='The delay in seconds before closing the sandbox after the agent is done.',
)
close_delay: int = Field(default=15)
remote_runtime_resource_factor: int = Field(default=1)
enable_gpu: bool = Field(default=False)
docker_runtime_kwargs: dict | None = Field(default=None)
@@ -1,19 +0,0 @@
import os
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.utils.import_utils import get_impl
class ExperimentManager:
@staticmethod
def run_conversation_variant_test(
user_id: str, conversation_id: str, conversation_settings: ConversationInitData
) -> ConversationInitData:
return conversation_settings
experiment_manager_cls = os.environ.get(
'OPENHANDS_EXPERIMENT_MANAGER_CLS',
'openhands.experiments.experiment_manager.ExperimentManager',
)
ExperimentManagerImpl = get_impl(ExperimentManager, experiment_manager_cls)
+24 -22
View File
@@ -483,33 +483,35 @@ class GitHubService(BaseGitService, GitService):
- PR URL when successful
- Error message when unsuccessful
"""
try:
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
# Set default body if none provided
if not body:
body = f'Merging changes from {source_branch} into {target_branch}'
# Set default body if none provided
if not body:
body = f'Merging changes from {source_branch} into {target_branch}'
# Prepare the request payload
payload = {
'title': title,
'head': source_branch,
'base': target_branch,
'body': body,
'draft': draft,
}
# Prepare the request payload
payload = {
'title': title,
'head': source_branch,
'base': target_branch,
'body': body,
'draft': draft,
}
# Make the POST request to create the PR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Make the POST request to create the PR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the HTML URL of the created PR
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'
# Return the HTML URL of the created PR
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'
except Exception as e:
return f'Error creating pull request: {str(e)}'
github_service_cls = os.environ.get(
+27 -26
View File
@@ -476,37 +476,38 @@ class GitLabService(BaseGitService, GitService):
- MR URL when successful
- Error message when unsuccessful
"""
try:
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Set default description if none provided
if not description:
description = (
f'Merging changes from {source_branch} into {target_branch}'
)
# Set default description if none provided
if not description:
description = (
f'Merging changes from {source_branch} into {target_branch}'
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
except Exception as e:
return f'Error creating merge request: {str(e)}'
gitlab_service_cls = os.environ.get(
+2 -2
View File
@@ -167,11 +167,11 @@ class BaseGitService(ABC):
return RateLimitError('GitHub API rate limit exceeded')
logger.warning(f'Status error on {self.provider} API: {e}')
return UnknownException(f'Unknown error: {e}')
return UnknownException('Unknown error')
def handle_http_error(self, e: HTTPError) -> UnknownException:
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
return UnknownException(f'HTTP error {type(e).__name__}')
class GitService(Protocol):
-2
View File
@@ -50,8 +50,6 @@ class View(BaseModel):
for event in events:
if isinstance(event, CondensationAction):
forgotten_event_ids.update(event.forgotten)
# Make sure we also forget the condensation action itself
forgotten_event_ids.add(event.id)
kept_events = [event for event in events if event.id not in forgotten_event_ids]
+43 -34
View File
@@ -1,7 +1,6 @@
import os
from functools import lru_cache
from typing import Callable
import typing
from uuid import UUID
import docker
@@ -42,7 +41,7 @@ APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def _is_retryablewait_until_alive_error(exception: Exception) -> bool:
def _is_retryablewait_until_alive_error(exception):
if isinstance(exception, tenacity.RetryError):
cause = exception.last_attempt.exception()
return _is_retryablewait_until_alive_error(cause)
@@ -141,10 +140,10 @@ class DockerRuntime(ActionExecutionClient):
)
@property
def action_execution_server_url(self) -> str:
def action_execution_server_url(self):
return self.api_url
async def connect(self) -> None:
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
try:
await call_sync_from_async(self._attach_to_container)
@@ -165,7 +164,7 @@ class DockerRuntime(ActionExecutionClient):
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
)
if DEBUG_RUNTIME and self.container:
if DEBUG_RUNTIME:
self.log_streamer = LogStreamer(self.container, self.log)
else:
self.log_streamer = None
@@ -265,7 +264,7 @@ class DockerRuntime(ActionExecutionClient):
return volumes
def init_container(self) -> None:
def init_container(self):
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
@@ -282,7 +281,7 @@ class DockerRuntime(ActionExecutionClient):
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
network_mode: typing.Literal['host'] | None = 'host' if use_host_network else None
network_mode: str | None = 'host' if use_host_network else None
# Initialize port mappings
port_mapping: dict[str, list[dict[str, str]]] | None = None
@@ -318,18 +317,15 @@ class DockerRuntime(ActionExecutionClient):
)
# Combine environment variables
environment = dict(**self.initial_env_vars)
environment.update(
{
'port': str(self._container_port),
'PYTHONUNBUFFERED': '1',
# Passing in the ports means nested runtimes do not come up with their own ports!
'VSCODE_PORT': str(self._vscode_port),
'APP_PORT_1': str(self._app_ports[0]),
'APP_PORT_2': str(self._app_ports[1]),
'PIP_BREAK_SYSTEM_PACKAGES': '1',
}
)
environment = {
'port': str(self._container_port),
'PYTHONUNBUFFERED': '1',
# Passing in the ports means nested runtimes do not come up with their own ports!
'VSCODE_PORT': str(self._vscode_port),
'APP_PORT_1': self._app_ports[0],
'APP_PORT_2': self._app_ports[1],
'PIP_BREAK_SYSTEM_PACKAGES': '1',
}
if self.config.debug or DEBUG:
environment['DEBUG'] = 'true'
# also update with runtime_startup_env_vars
@@ -354,8 +350,6 @@ class DockerRuntime(ActionExecutionClient):
command = self.get_action_execution_server_startup_command()
try:
if self.runtime_container_image is None:
raise ValueError("Runtime container image is not set")
self.container = self.docker_client.containers.run(
self.runtime_container_image,
command=command,
@@ -367,7 +361,7 @@ class DockerRuntime(ActionExecutionClient):
name=self.container_name,
detach=True,
environment=environment,
volumes=volumes, # type: ignore
volumes=volumes,
device_requests=(
[docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)]
if self.config.sandbox.enable_gpu
@@ -377,15 +371,32 @@ class DockerRuntime(ActionExecutionClient):
)
self.log('debug', f'Container started. Server url: {self.api_url}')
self.send_status_message('STATUS$CONTAINER_STARTED')
except docker.errors.APIError as e:
if '409' in str(e):
self.log(
'warning',
f'Container {self.container_name} already exists. Removing...',
)
stop_all_containers(self.container_name)
return self.init_container()
else:
self.log(
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
raise e
except Exception as e:
self.log(
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
self.close()
raise e
def _attach_to_container(self) -> None:
def _attach_to_container(self):
self.container = self.docker_client.containers.get(self.container_name)
if self.container.status == 'exited':
self.container.start()
@@ -421,7 +432,7 @@ class DockerRuntime(ActionExecutionClient):
reraise=True,
wait=tenacity.wait_fixed(2),
)
def wait_until_alive(self) -> None:
def wait_until_alive(self):
try:
container = self.docker_client.containers.get(self.container_name)
if container.status == 'exited':
@@ -435,7 +446,7 @@ class DockerRuntime(ActionExecutionClient):
self.check_if_alive()
def close(self, rm_all_containers: bool | None = None) -> None:
def close(self, rm_all_containers: bool | None = None):
"""Closes the DockerRuntime and associated objects
Parameters:
@@ -455,7 +466,7 @@ class DockerRuntime(ActionExecutionClient):
)
stop_all_containers(close_prefix)
def _is_port_in_use_docker(self, port: int) -> bool:
def _is_port_in_use_docker(self, port):
containers = self.docker_client.containers.list()
for container in containers:
container_ports = container.ports
@@ -463,9 +474,7 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> int:
def _find_available_port(self, port_range, max_attempts=5):
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
@@ -484,7 +493,7 @@ class DockerRuntime(ActionExecutionClient):
return vscode_url
@property
def web_hosts(self) -> dict[str, int]:
def web_hosts(self):
hosts: dict[str, int] = {}
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost')
@@ -493,7 +502,7 @@ class DockerRuntime(ActionExecutionClient):
return hosts
def pause(self) -> None:
def pause(self):
"""Pause the runtime by stopping the container.
This is different from container.stop() as it ensures environment variables are properly preserved."""
if not self.container:
@@ -506,7 +515,7 @@ class DockerRuntime(ActionExecutionClient):
self.container.stop()
self.log('debug', f'Container {self.container_name} paused')
def resume(self) -> None:
def resume(self):
"""Resume the runtime by starting the container.
This is different from container.start() as it ensures environment variables are properly restored."""
if not self.container:
@@ -520,7 +529,7 @@ class DockerRuntime(ActionExecutionClient):
self.wait_until_alive()
@classmethod
async def delete(cls, conversation_id: str) -> None:
async def delete(cls, conversation_id: str):
docker_client = cls._init_docker_client()
try:
container_name = CONTAINER_NAME_PREFIX + conversation_id
@@ -533,7 +542,7 @@ class DockerRuntime(ActionExecutionClient):
finally:
docker_client.close()
def get_action_execution_server_startup_command(self) -> list[str]:
def get_action_execution_server_startup_command(self):
return get_action_execution_server_startup_command(
server_port=self._container_port,
plugins=self.plugins,
+2 -2
View File
@@ -48,12 +48,12 @@ class ServerConfig(ServerConfigInterface):
return config
def load_server_config() -> ServerConfig:
def load_server_config():
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
logger.info(f'Using config class {config_cls}')
server_config_cls = get_impl(ServerConfig, config_cls)
server_config : ServerConfig = server_config_cls()
server_config = server_config_cls()
server_config.verify_config()
return server_config
@@ -54,7 +54,6 @@ class DockerNestedConversationManager(ConversationManager):
docker_client: docker.DockerClient = field(default_factory=docker.from_env)
_conversation_store_class: type[ConversationStore] | None = None
_starting_conversation_ids: set[str] = field(default_factory=set)
_runtime_container_image: str | None = None
async def __aenter__(self):
# No action is required on startup for this implementation
@@ -90,8 +89,7 @@ class DockerNestedConversationManager(ConversationManager):
"""
Get the running agent loops directly from docker.
"""
containers : list[Container] = self.docker_client.containers.list()
names = (container.name or '' for container in containers)
names = (container.name for container in self.docker_client.containers.list())
conversation_ids = {
name[len('openhands-runtime-') :]
for name in names
@@ -157,12 +155,6 @@ class DockerNestedConversationManager(ConversationManager):
try:
# Build the runtime container image if it is missing
await call_sync_from_async(runtime.maybe_build_runtime_container_image)
self._runtime_container_image = runtime.runtime_container_image
# check that the container already exists...
if await self._start_existing_container(runtime):
self._starting_conversation_ids.discard(sid)
return
# initialize the container but dont wait for it to start
await call_sync_from_async(runtime.init_container)
@@ -180,7 +172,7 @@ class DockerNestedConversationManager(ConversationManager):
)
except Exception:
self._starting_conversation_ids.discard(sid)
self._starting_conversation_ids.remove(sid)
raise
async def _start_conversation(
@@ -270,7 +262,7 @@ class DockerNestedConversationManager(ConversationManager):
)
assert response.status_code == status.HTTP_200_OK
finally:
self._starting_conversation_ids.discard(sid)
self._starting_conversation_ids.remove(sid)
async def send_to_event_stream(self, connection_id: str, data: dict):
# Not supported - clients should connect directly to the nested server!
@@ -283,11 +275,11 @@ class DockerNestedConversationManager(ConversationManager):
async def close_session(self, sid: str):
stop_all_containers(f'openhands-runtime-{sid}')
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
results = []
containers : list[Container] = self.docker_client.containers.list()
containers = self.docker_client.containers.list()
for container in containers:
if not container.name or not container.name.startswith('openhands-runtime-'):
if not container.name.startswith('openhands-runtime-'):
continue
conversation_id = container.name[len('openhands-runtime-') :]
if filter_to_sids is not None and conversation_id not in filter_to_sids:
@@ -350,12 +342,11 @@ class DockerNestedConversationManager(ConversationManager):
def get_nested_url_for_container(self, container: Container) -> str:
env = container.attrs['Config']['Env']
container_port = int(next(e[5:] for e in env if e.startswith('port=')))
container_name = container.name or ''
conversation_id = container_name[len('openhands-runtime-') :]
conversation_id = container.name[len('openhands-runtime-') :]
nested_url = f'{self.config.sandbox.local_runtime_url}:{container_port}/api/conversations/{conversation_id}'
return nested_url
def _get_session_api_key_for_conversation(self, conversation_id: str) -> str:
def _get_session_api_key_for_conversation(self, conversation_id: str):
jwt_secret = self.config.jwt_secret.get_secret_value() # type:ignore
conversation_key = f'{jwt_secret}:{conversation_id}'.encode()
session_api_key = (
@@ -365,7 +356,7 @@ class DockerNestedConversationManager(ConversationManager):
)
return session_api_key
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None) -> None:
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None):
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= self.config.max_concurrent_conversations:
logger.info(
@@ -397,7 +388,7 @@ class DockerNestedConversationManager(ConversationManager):
)
await self.close_session(oldest_conversation_id)
def _get_provider_handler(self, settings: Settings) -> ProviderHandler:
def _get_provider_handler(self, settings: Settings):
provider_tokens = None
if isinstance(settings, ConversationInitData):
provider_tokens = settings.git_provider_tokens
@@ -407,7 +398,7 @@ class DockerNestedConversationManager(ConversationManager):
)
return provider_handler
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings) -> DockerRuntime:
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings):
# This session is created here only because it is the easiest way to get a runtime, which
# is the easiest way to create the needed docker container
session = Session(
@@ -440,7 +431,6 @@ class DockerNestedConversationManager(ConversationManager):
# We need to be able to specify the nested conversation id within the nested runtime
env_vars['ALLOW_SET_CONVERSATION_ID'] = '1'
env_vars['WORKSPACE_BASE'] = f'/workspace'
env_vars['SANDBOX_CLOSE_DELAY'] = '0'
# Set up mounted volume for conversation directory within workspace
# TODO: Check if we are using the standard event store and file store
@@ -450,13 +440,10 @@ class DockerNestedConversationManager(ConversationManager):
else:
volumes = [v.strip() for v in config.sandbox.volumes.split(',')]
conversation_dir = get_conversation_dir(sid, user_id)
volumes.append(
f'{config.file_store_path}/{conversation_dir}:/root/.openhands/file_store/{conversation_dir}:rw'
f'{config.file_store_path}/{conversation_dir}:{OpenHandsConfig.model_fields["file_store_path"].default}/{conversation_dir}:rw'
)
config.sandbox.volumes = ','.join(volumes)
if not config.sandbox.runtime_container_image:
config.sandbox.runtime_container_image = self._runtime_container_image
# Currently this eventstream is never used and only exists because one is required in order to create a docker runtime
event_stream = EventStream(sid, self.file_store, user_id)
@@ -476,18 +463,6 @@ class DockerNestedConversationManager(ConversationManager):
return runtime
async def _start_existing_container(self, runtime: DockerRuntime) -> bool:
try:
container = self.docker_client.containers.get(runtime.container_name)
if container:
status = container.status
if status == 'exited':
await call_sync_from_async(container.start)
return True
return False
except docker.errors.NotFound as e:
return False
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
last_updated_at = conversation.last_updated_at
@@ -111,8 +111,7 @@ class StandaloneConversationManager(ConversationManager):
return None
end_time = time.time()
logger.info(
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds',
extra={'session_id': sid}
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds'
)
self._active_conversations[sid] = (c, 1)
return c
@@ -155,10 +154,6 @@ class StandaloneConversationManager(ConversationManager):
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
# Implies disconnected sandboxes stay open indefinitely
if not self.config.sandbox.close_delay:
return
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
+3 -10
View File
@@ -19,7 +19,6 @@ from openhands.events.observation.agent import (
AgentStateChangedObservation,
)
from openhands.events.serialization import event_to_dict
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.session.conversation_init_data import ConversationInitData
@@ -50,7 +49,7 @@ def create_provider_tokens_object(
async def setup_init_convo_settings(
user_id: str | None, conversation_id: str, providers_set: list[ProviderType]
user_id: str | None, providers_set: list[ProviderType]
) -> ConversationInitData:
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
settings = await settings_store.load()
@@ -74,11 +73,7 @@ async def setup_init_convo_settings(
if user_secrets:
session_init_args['custom_secrets'] = user_secrets.custom_secrets
convo_init_data = ConversationInitData(**session_init_args)
# We should recreate the same experiment conditions when restarting a conversation
return ExperimentManagerImpl.run_conversation_variant_test(
user_id, conversation_id, convo_init_data
)
return ConversationInitData(**session_init_args)
@sio.event
@@ -124,9 +119,7 @@ async def connect(connection_id: str, environ: dict) -> None:
f'User {user_id} is allowed to connect to conversation {conversation_id}'
)
conversation_init_data = await setup_init_convo_settings(
user_id, conversation_id, providers_set
)
conversation_init_data = await setup_init_convo_settings(user_id, providers_set)
agent_loop_info = await conversation_manager.join_conversation(
conversation_id,
connection_id,
+1 -1
View File
@@ -145,5 +145,5 @@ async def search_events(
@app.post('/events')
async def add_event(request: Request, conversation: ServerConversation = Depends(get_conversation)):
data = request.json()
await conversation_manager.send_to_event_stream(conversation.sid, data)
conversation_manager.send_to_event_stream(conversation.sid, data)
return JSONResponse({'success': True})
+5 -113
View File
@@ -1,3 +1,4 @@
import asyncio
import os
import uuid
from datetime import datetime, timezone
@@ -35,7 +36,6 @@ from openhands.server.user_auth import (
get_provider_tokens,
get_user_id,
get_user_secrets,
get_user_settings,
)
from openhands.server.user_auth.user_auth import AuthType
from openhands.server.utils import get_conversation_store
@@ -45,7 +45,6 @@ from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
)
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.storage.data_models.settings import Settings
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import get_default_conversation_title
@@ -69,11 +68,10 @@ class InitSessionRequest(BaseModel):
model_config = {'extra': 'forbid'}
class ConversationResponse(BaseModel):
class InitSessionResponse(BaseModel):
status: str
conversation_id: str
message: str | None = None
conversation_status: ConversationStatus | None = None
@app.post('/conversations')
@@ -83,7 +81,7 @@ async def new_conversation(
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
user_secrets: UserSecrets = Depends(get_user_secrets),
auth_type: AuthType | None = Depends(get_auth_type),
) -> ConversationResponse:
) -> InitSessionResponse:
"""Initialize a new session or join an existing one.
After successful initialization, the client should connect to the WebSocket
@@ -128,7 +126,7 @@ async def new_conversation(
await provider_handler.verify_repo_provider(repository, git_provider)
conversation_id = getattr(data, 'conversation_id', None) or uuid.uuid4().hex
agent_loop_info = await create_new_conversation(
await create_new_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
@@ -143,10 +141,9 @@ async def new_conversation(
conversation_id=conversation_id,
)
return ConversationResponse(
return InitSessionResponse(
status='ok',
conversation_id=conversation_id,
conversation_status=agent_loop_info.status,
)
except MissingSettingsError as e:
return JSONResponse(
@@ -306,108 +303,3 @@ async def _get_conversation_info(
extra={'session_id': conversation.conversation_id},
)
return None
@app.post('/conversations/{conversation_id}/start')
async def start_conversation(
conversation_id: str,
user_id: str = Depends(get_user_id),
settings: Settings = Depends(get_user_settings),
conversation_store: ConversationStore = Depends(get_conversation_store),
) -> ConversationResponse:
"""Start an agent loop for a conversation.
This endpoint calls the conversation_manager's maybe_start_agent_loop method
to start a conversation. If the conversation is already running, it will
return the existing agent loop info.
"""
logger.info(f'Starting conversation: {conversation_id}')
try:
# Check that the conversation exists
try:
await conversation_store.get_metadata(conversation_id)
except Exception:
return JSONResponse(
content={
'status': 'error',
'conversation_id': conversation_id,
},
status_code=status.HTTP_404_NOT_FOUND,
)
# Start the agent loop
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
sid=conversation_id,
settings=settings,
user_id=user_id,
)
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
conversation_status=agent_loop_info.status,
)
except Exception as e:
logger.error(
f'Error starting conversation {conversation_id}: {str(e)}',
extra={'session_id': conversation_id},
)
return JSONResponse(
content={
'status': 'error',
'conversation_id': conversation_id,
'message': f'Failed to start conversation: {str(e)}',
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@app.post('/conversations/{conversation_id}/stop')
async def stop_conversation(
conversation_id: str,
user_id: str = Depends(get_user_id),
) -> ConversationResponse:
"""Stop an agent loop for a conversation.
This endpoint calls the conversation_manager's close_session method
to stop a conversation.
"""
logger.info(f'Stopping conversation: {conversation_id}')
try:
# Check if the conversation is running
agent_loop_info = await conversation_manager.get_agent_loop_info(user_id=user_id, filter_to_sids={conversation_id})
conversation_status = agent_loop_info[0].status if agent_loop_info else ConversationStatus.STOPPED
if conversation_status not in (ConversationStatus.STARTING, ConversationStatus.RUNNING):
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
message='Conversation was not running',
conversation_status=conversation_status,
)
# Stop the conversation
await conversation_manager.close_session(conversation_id)
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
message='Conversation stopped successfully',
conversation_status=conversation_status,
)
except Exception as e:
logger.error(
f'Error stopping conversation {conversation_id}: {str(e)}',
extra={'session_id': conversation_id},
)
return JSONResponse(
content={
'status': 'error',
'conversation_id': conversation_id,
'message': f'Failed to stop conversation: {str(e)}',
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+3 -6
View File
@@ -2,7 +2,6 @@ import re
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_http_request
from pydantic import Field
@@ -20,7 +19,7 @@ from openhands.server.user_auth import (
)
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
mcp_server = FastMCP('mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True)
mcp_server = FastMCP('mcp', stateless_http=True, dependencies=get_dependencies())
async def save_pr_metadata(
user_id: str, conversation_id: str, tool_result: str
@@ -97,8 +96,7 @@ async def create_pr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
error = f"Error creating pull request: {e}"
raise ToolError(str(error))
response = str(e)
return response
@@ -153,7 +151,6 @@ async def create_mr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
error = f"Error creating merge request: {e}"
raise ToolError(str(error))
response = str(e)
return response
+2 -2
View File
@@ -15,7 +15,6 @@ from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
@@ -36,9 +35,10 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings_store: SettingsStore = Depends(get_user_settings_store),
settings: Settings = Depends(get_user_settings),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
settings = await settings_store.load()
try:
if not settings:
return JSONResponse(
@@ -4,7 +4,6 @@ from typing import Any
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import (
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
PROVIDER_TOKEN_TYPE,
@@ -79,8 +78,6 @@ async def create_new_conversation(
session_init_args['git_provider'] = git_provider
session_init_args['conversation_instructions'] = conversation_instructions
conversation_init_data = ConversationInitData(**session_init_args)
logger.info('Loading conversation store')
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
logger.info('ServerConversation store loaded')
@@ -96,7 +93,6 @@ async def create_new_conversation(
extra={'user_id': user_id, 'session_id': conversation_id},
)
conversation_init_data = ExperimentManagerImpl.run_conversation_variant_test(user_id, conversation_id, conversation_init_data)
conversation_title = get_default_conversation_title(conversation_id)
logger.info(f'Saving metadata for conversation {conversation_id}')
@@ -109,7 +105,6 @@ async def create_new_conversation(
selected_repository=selected_repository,
selected_branch=selected_branch,
git_provider=git_provider,
llm_model=settings.llm_model,
)
)
@@ -25,10 +25,6 @@ class DefaultUserAuth(UserAuth):
"""The default implementation does not support multi tenancy, so user_id is always None"""
return None
async def get_user_email(self) -> str | None:
"""The default implementation does not support multi tenancy, so email is always None"""
return None
async def get_access_token(self) -> SecretStr | None:
"""The default implementation does not support multi tenancy, so access_token is always None"""
return None
-4
View File
@@ -38,10 +38,6 @@ class UserAuth(ABC):
async def get_user_id(self) -> str | None:
"""Get the unique identifier for the current user"""
@abstractmethod
async def get_user_email(self) -> str | None:
"""Get the email for the current user"""
@abstractmethod
async def get_access_token(self) -> SecretStr | None:
"""Get the access token for the current user"""
+4 -13
View File
@@ -1,8 +1,7 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, Request
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.storage.conversation.conversation_store import ConversationStore
@@ -12,7 +11,8 @@ async def get_conversation_store(request: Request) -> ConversationStore | None:
)
if conversation_store:
return conversation_store
user_id = get_user_id(request)
user_auth = await get_user_auth(request)
user_id = await user_auth.get_user_id()
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
request.state.conversation_store = conversation_store
return conversation_store
@@ -25,15 +25,6 @@ async def get_conversation(
conversation = await conversation_manager.attach_to_conversation(
conversation_id, user_id
)
if not conversation:
logger.warn(
f'get_conversation: conversation {conversation_id} not found, attach_to_conversation returned None',
extra={'session_id': conversation_id, 'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Conversation {conversation_id} not found',
)
try:
yield conversation
finally:
@@ -25,7 +25,6 @@ class ConversationMetadata:
trigger: ConversationTrigger | None = None
pr_number: list[int] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
llm_model: str | None = None
# Cost and token metrics
accumulated_cost: float = 0.0
prompt_tokens: int = 0
@@ -40,8 +40,6 @@ class Settings(BaseModel):
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
search_api_key: SecretStr | None = None
email: str | None = None
email_verified: bool | None = None
model_config = {
'validate_assignment': True,
+6 -8
View File
@@ -826,19 +826,17 @@ async def test_context_window_exceeded_error_handling(
# size (because we return a message action, which triggers a recall, which
# triggers a recall response). But if the pre/post-views are on the turn
# when we throw the context window exceeded error, we should see the
# post-step view compressed (condensation effects should be visible).
# post-step view compressed (or rather, a CondensationAction added).
for index, (first_view, second_view) in enumerate(
zip(step_state.views[:-1], step_state.views[1:])
):
if index == error_after:
# Verify that no CondensationAction is present in either view
# (CondensationAction events are never included in views)
# Verify that the CondensationAction is present in the second view (after error)
# but not in the first view (before error)
assert not any(isinstance(e, CondensationAction) for e in first_view.events)
assert not any(
isinstance(e, CondensationAction) for e in second_view.events
)
# The view length should be compressed due to condensation effects
assert len(first_view) > len(second_view)
assert any(isinstance(e, CondensationAction) for e in second_view.events)
# The length might not strictly decrease due to CondensationAction being added
assert len(first_view) == len(second_view)
else:
# Before the error, the view length should increase
assert len(first_view) < len(second_view)
+5 -9
View File
@@ -20,8 +20,8 @@ from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.server.routes.manage_conversations import (
ConversationResponse,
InitSessionRequest,
InitSessionResponse,
delete_conversation,
get_conversation,
new_conversation,
@@ -250,7 +250,6 @@ async def test_new_conversation_success(provider_handler_mock):
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
test_request = InitSessionRequest(
@@ -264,7 +263,7 @@ async def test_new_conversation_success(provider_handler_mock):
response = await create_new_test_conversation(test_request)
# Verify the response
assert isinstance(response, ConversationResponse)
assert isinstance(response, InitSessionResponse)
assert response.status == 'ok'
# Don't check the exact conversation_id as it's now generated dynamically
assert response.conversation_id is not None
@@ -294,7 +293,6 @@ async def test_new_conversation_with_suggested_task(provider_handler_mock):
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
# Mock SuggestedTask.get_prompt_for_task
@@ -323,7 +321,7 @@ async def test_new_conversation_with_suggested_task(provider_handler_mock):
response = await create_new_test_conversation(test_request)
# Verify the response
assert isinstance(response, ConversationResponse)
assert isinstance(response, InitSessionResponse)
assert response.status == 'ok'
# Don't check the exact conversation_id as it's now generated dynamically
assert response.conversation_id is not None
@@ -481,7 +479,6 @@ async def test_new_conversation_with_bearer_auth(provider_handler_mock):
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
# Create the request object
@@ -495,7 +492,7 @@ async def test_new_conversation_with_bearer_auth(provider_handler_mock):
response = await create_new_test_conversation(test_request, AuthType.BEARER)
# Verify the response
assert isinstance(response, ConversationResponse)
assert isinstance(response, InitSessionResponse)
assert response.status == 'ok'
# Verify that create_new_conversation was called with REMOTE_API_KEY trigger
@@ -519,7 +516,6 @@ async def test_new_conversation_with_null_repository():
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
# Create the request object with null repository
@@ -533,7 +529,7 @@ async def test_new_conversation_with_null_repository():
response = await create_new_test_conversation(test_request)
# Verify the response
assert isinstance(response, ConversationResponse)
assert isinstance(response, InitSessionResponse)
assert response.status == 'ok'
# Verify that create_new_conversation was called with None repository
@@ -28,9 +28,6 @@ class MockUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return 'test-user'
async def get_user_email(self) -> str | None:
return 'test-email@whatever.com'
async def get_access_token(self) -> SecretStr | None:
return SecretStr('test-token')
-3
View File
@@ -27,9 +27,6 @@ class MockUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return 'test-user'
async def get_user_email(self) -> str | None:
return 'test-email@whatever.com'
async def get_access_token(self) -> SecretStr | None:
return SecretStr('test-token')
-104
View File
@@ -1,104 +0,0 @@
from openhands.events.action.agent import CondensationAction
from openhands.events.action.message import MessageAction
from openhands.events.event import Event
from openhands.events.observation.agent import AgentCondensationObservation
from openhands.memory.view import View
def test_view_preserves_uncondensed_lists() -> None:
"""Tests that the view preserves event lists that don't contain condensation actions."""
events: list[Event] = [MessageAction(content=f'Event {i}') for i in range(5)]
set_ids(events)
view = View.from_events(events)
assert len(view) == 5
assert view.events == events
def test_view_forgets_events() -> None:
"""Tests that views drop forgotten events and the condensation actions."""
events: list[Event] = [
*[MessageAction(content=f'Event {i}') for i in range(5)],
CondensationAction(forgotten_event_ids=[0, 1, 2, 3, 4]),
]
set_ids(events)
view = View.from_events(events)
assert view.events == [] # All events forgotten and condensation action removed
def test_view_keeps_non_forgotten_events() -> None:
"""Tests that views keep non-forgotten events."""
for forgotten_event_id in range(5):
events: list[Event] = [
*[MessageAction(content=f'Event {i}') for i in range(5)],
# Instead of forgetting all events like in
# `test_view_forgets_events`, in this test we only want to forget
# one of the events. That way we can check that the rest of the
# events are preserved.
CondensationAction(forgotten_event_ids=[forgotten_event_id]),
]
set_ids(events)
view = View.from_events(events)
assert (
view.events
== events[:forgotten_event_id] + events[(forgotten_event_id + 1) : 5]
)
def test_view_inserts_summary() -> None:
"""Tests that views insert a summary observation at the specified offset."""
for offset in range(5):
events: list[Event] = [
*[MessageAction(content=f'Event {i}') for i in range(5)],
CondensationAction(
forgotten_event_ids=[], summary='My Summary', summary_offset=offset
),
]
set_ids(events)
view = View.from_events(events)
assert len(view) == 6 # 5 message events + 1 summary observation
for index, event in enumerate(view):
print(index, event.content)
if index == offset:
assert isinstance(event, AgentCondensationObservation)
assert event.content == 'My Summary'
# Events before where the summary is inserted will have content
# matching their index.
elif index < offset:
assert isinstance(event, MessageAction)
assert event.content == f'Event {index}'
# Events after where the summary is inserted will be offset by one
# from the original list.
else:
assert isinstance(event, MessageAction)
assert event.content == f'Event {index - 1}'
def test_no_condensation_action_in_view() -> None:
"""Ensure that CondensationAction events are never present in the resulting view."""
events: list[Event] = [
MessageAction(content='Event 0'),
MessageAction(content='Event 1'),
CondensationAction(forgotten_event_ids=[0]),
MessageAction(content='Event 2'),
MessageAction(content='Event 3'),
]
set_ids(events)
view = View.from_events(events)
# Check that no CondensationAction is present in the view
for event in view:
assert not isinstance(event, CondensationAction)
# The view should only contain the non-forgotten MessageActions
assert len(view) == 3 # Event 1, Event 2, Event 3 (Event 0 was forgotten)
def set_ids(events: list[Event]) -> None:
"""Set the IDs of the events in the list to their index."""
for i, e in enumerate(events):
e._id = i # type: ignore