Compare commits

..

15 Commits

Author SHA1 Message Date
Rohit Malhotra
164fab0a8d Add tests for Git LFS and clone depth support 2025-02-20 12:45:25 -05:00
Rohit Malhotra
bddf6674c3 Add Git LFS and clone depth support 2025-02-20 12:45:24 -05:00
Rohit Malhotra
0180ce77b1 [Bug]: Fix workflow definition for installation phase of resolver (#6861) 2025-02-20 16:40:23 +00:00
sp.wack
2f14e53746 chore(frontend): Standardize custom colors used throughout the app (#6833) 2025-02-20 16:13:50 +00:00
Robert Brennan
52723061b1 Add conversation age limit configuration (#6763)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-20 10:50:17 -05:00
tofarr
42f1fc92fa Fix: Less squashed logo (#6853) 2025-02-20 07:56:20 -07:00
sp.wack
3f8bc8a7ea hotfix: Set proper minimum and maximum defaults that can be entered in billing input (#6842) 2025-02-20 17:58:23 +04:00
sp.wack
f869ad995c hotfix: Remove external link in billing settings UI (#6841) 2025-02-20 17:58:09 +04:00
Calvin Smith
74c942c911 fix: LLM summarization prompt handles user messages (#6837)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-02-19 14:17:48 -07:00
Engel Nyst
eed7e2dd6e Refactor I/O utils; allow 'task' command line parameter in cli.py (#6187)
Co-authored-by: OpenHands Bot <openhands@all-hands.dev>
2025-02-19 22:10:14 +01:00
Engel Nyst
663e36109c Clean up NullObservations from the stream (#6260) 2025-02-19 20:40:40 +01:00
mamoodi
e92e4a1cbc Update documentation with new settings page (#6716)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-19 14:30:36 -05:00
mamoodi
61ce673400 Release 0.25.0 (#6782) 2025-02-19 14:30:05 -05:00
Engel Nyst
b95840db0c hotfix azure (#6806) 2025-02-19 19:24:35 +01:00
Xingyao Wang
003ebc0ded feat: better error logging for remote runtime (#6805) 2025-02-19 17:54:34 +00:00
74 changed files with 495 additions and 318 deletions

View File

@@ -88,12 +88,10 @@ jobs:
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
# Ensure requirements.txt ends with newline before appending
if [ -f requirements.txt ] && [ -s requirements.txt ]; then
sed -i -e '$a\' requirements.txt
fi
echo "openhands-ai==${OPENHANDS_VERSION}" >> requirements.txt
cat requirements.txt
# Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
cat /tmp/requirements.txt
- name: Cache pip dependencies
if: |
@@ -111,9 +109,9 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('requirements.txt') }}
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('requirements.txt') }}
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
- name: Check required environment variables
env:
@@ -225,7 +223,7 @@ jobs:
} else {
console.log("Installing from requirements.txt...");
await exec.exec("python -m pip install --upgrade pip");
await exec.exec("pip install -r requirements.txt");
await exec.exec("pip install -r /tmp/requirements.txt");
}
- name: Attempt to resolve issue

View File

@@ -46,3 +46,11 @@ docker run -it \
-e THAT=that
...
```
### Referring to UI Elements
When referencing UI elements, use ``.
Example:
1. Toggle the `Advanced` option
2. Enter your model in the `Custom Model` textbox.

View File

@@ -1,9 +1,6 @@
# GUI Mode
## Introduction
OpenHands provides a user-friendly Graphical User Interface (GUI) mode for interacting with the AI assistant.
This mode offers an intuitive way to set up the environment, manage settings, and communicate with the AI.
OpenHands provides a Graphical User Interface (GUI) mode for interacting with the AI assistant.
## Installation and Setup
@@ -14,104 +11,95 @@ This mode offers an intuitive way to set up the environment, manage settings, an
### Initial Setup
1. Upon first launch, you'll see a settings modal.
2. Select an `LLM Provider` and `LLM Model` from the dropdown menus.
1. Upon first launch, you'll see a settings page.
2. Select an `LLM Provider` and `LLM Model` from the dropdown menus. If the required model does not exist in the list,
toggle `Advanced` options and enter it with the correct prefix in the `Custom Model` text box.
3. Enter the corresponding `API Key` for your chosen provider.
4. Click "Save" to apply the settings.
4. Click `Save Changes` to apply the settings.
### GitHub Token Setup
OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if it is available. This can happen in two ways:
- **Locally (OSS)**: The user directly inputs their GitHub token.
- **Online (SaaS)**: The token is obtained through GitHub OAuth authentication.
#### Setting Up a Local GitHub Token
1. **Generate a Personal Access Token (PAT)**:
- Go to GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic).
- Click "Generate new token (classic)".
- **Local Installation**: The user directly inputs their GitHub token.
<details>
<summary>Setting Up a GitHub Token</summary>
1. **Generate a Personal Access Token (PAT)**:
- On GitHub, go to Settings > Developer Settings > Personal Access Tokens > Tokens (classic).
- Click `Generate new token (classic)`.
- Required scopes:
- `repo` (Full control of private repositories)
- `workflow` (Update GitHub Action workflows)
- `read:org` (Read organization data)
2. **Enter Token in OpenHands**:
- Click the Settings button (gear icon).
- Navigate to the `GitHub Settings` section.
- Paste your token in the `GitHub Token` field.
- Click `Save Changes` to apply the changes.
</details>
2. **Enter Token in OpenHands**:
- Click the Settings button (gear icon) in the top right.
- Navigate to the "GitHub" section.
- Paste your token in the "GitHub Token" field.
- Click "Save" to apply the changes.
<details>
<summary>Organizational Token Policies</summary>
#### Organizational Token Policies
If you're working with organizational repositories, additional setup may be required:
If you're working with organizational repositories, additional setup may be required:
1. **Check Organization Requirements**:
1. **Check Organization Requirements**:
- Organization admins may enforce specific token policies.
- Some organizations require tokens to be created with SSO enabled.
- Review your organization's [token policy settings](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization).
2. **Verify Organization Access**:
2. **Verify Organization Access**:
- Go to your token settings on GitHub.
- Look for the organization under "Organization access".
- If required, click "Enable SSO" next to your organization.
- Look for the organization under `Organization access`.
- If required, click `Enable SSO` next to your organization.
- Complete the SSO authorization process.
</details>
#### OAuth Authentication (Online Mode)
<details>
<summary>Troubleshooting</summary>
When using OpenHands in online mode, the GitHub OAuth flow:
Common issues and solutions:
1. Requests the following permissions:
- **Token Not Recognized**:
- Ensure the token is properly saved in settings.
- Check that the token hasn't expired.
- Verify the token has the required scopes.
- Try regenerating the token.
- **Organization Access Denied**:
- Check if SSO is required but not enabled.
- Verify organization membership.
- Contact organization admin if token policies are blocking access.
- **Verifying Token Works**:
- The app will show a green checkmark if the token is valid.
- Try accessing a repository to confirm permissions.
- Check the browser console for any error messages.
</details>
- **OpenHands Cloud**: The token is obtained through GitHub OAuth authentication.
<details>
<summary>OAuth Authentication</summary>
When using OpenHands Cloud, the GitHub OAuth flow requests the following permissions:
- Repository access (read/write)
- Workflow management
- Organization read access
2. Authentication steps:
- Click "Sign in with GitHub" when prompted.
To authenticate OpenHands:
- Click `Sign in with GitHub` when prompted.
- Review the requested permissions.
- Authorize OpenHands to access your GitHub account.
- If using an organization, authorize organization access if prompted.
#### Troubleshooting
Common issues and solutions:
- **Token Not Recognized**:
- Ensure the token is properly saved in settings.
- Check that the token hasn't expired.
- Verify the token has the required scopes.
- Try regenerating the token.
- **Organization Access Denied**:
- Check if SSO is required but not enabled.
- Verify organization membership.
- Contact organization admin if token policies are blocking access.
- **Verifying Token Works**:
- The app will show a green checkmark if the token is valid.
- Try accessing a repository to confirm permissions.
- Check the browser console for any error messages.
- Use the "Test Connection" button in settings if available.
</details>
### Advanced Settings
1. Toggle `Advanced Options` to access additional settings.
1. Inside the Settings page, toggle `Advanced` options to access additional settings.
2. Use the `Custom Model` text box to manually enter a model if it's not in the list.
3. Specify a `Base URL` if required by your LLM provider.
### Main Interface
The main interface consists of several key components:
- **Chat Window**: The central area where you can view the conversation history with the AI assistant.
- **Input Box**: Located at the bottom of the screen, use this to type your messages or commands to the AI.
- **Send Button**: Click this to send your message to the AI.
- **Settings Button**: A gear icon that opens the settings modal, allowing you to adjust your configuration at any time.
- **Workspace Panel**: Displays the files and folders in your workspace, allowing you to navigate and view files, or the agent's past commands or web browsing history.
### Interacting with the AI
1. Type your question, request, or task description in the input box.
1. Type your prompt in the input box.
2. Click the send button or press Enter to submit your message.
3. The AI will process your input and provide a response in the chat window.
4. You can continue the conversation by asking follow-up questions or providing additional information.

View File

@@ -12,7 +12,8 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
<details>
<summary>MacOS</summary>
### Docker Desktop
**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.
@@ -25,7 +26,7 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
Tested with Ubuntu 22.04.
:::
### Docker Desktop
**Docker Desktop**
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
@@ -33,12 +34,13 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
<details>
<summary>Windows</summary>
### WSL
**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
**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:
@@ -78,24 +80,22 @@ or run it on tagged issues with [a github action](https://docs.all-hands.dev/mod
## Setup
Upon launching OpenHands, you'll see a settings modal. You **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
Upon launching OpenHands, you'll see a Settings page. You **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
These can be changed at any time by selecting the `Settings` button (gear icon) in the UI.
If the required `LLM Model` does not exist in the list, you can toggle `Advanced Options` and manually enter it with the correct prefix
If the required model does not exist in the list, 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.
The `Advanced` options also allow you to specify a `Base URL` if required.
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
<img src="/img/settings-screenshot.png" alt="settings-modal" width="340" />
<img src="/img/settings-advanced.png" alt="settings-modal" width="335" />
</div>
Now you're ready to [get started with OpenHands](./getting-started).
## Versions
The command above pulls the most recent stable release of OpenHands. You have other options as well:
- For a specific release, use `docker.all-hands.dev/all-hands-ai/openhands:$VERSION`, replacing $VERSION with the version number.
- We use semver, and release major, minor, and patch tags. 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, you can use `docker.all-hands.dev/all-hands-ai/openhands:main`. This version is unstable and is recommended for testing or development purposes only.
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.
You can choose the tag that best suits your needs based on stability requirements and desired features.

View File

@@ -25,7 +25,7 @@ You will need your ChatGPT deployment name which can be found on the deployments
&lt;deployment-name&gt; below.
:::
1. Enable `Advanced Options`
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to azure/&lt;deployment-name&gt;
- `Base URL` to your Azure API Base URL (e.g. `https://example-endpoint.openai.azure.com`)

View File

@@ -10,7 +10,7 @@ OpenHands uses LiteLLM to make calls to Google's chat models. You can find their
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
- `LLM Provider` to `Gemini`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced Options`, and enter it in `Custom Model` (e.g. gemini/&lt;model-name&gt; like `gemini/gemini-1.5-pro`).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. gemini/&lt;model-name&gt; like `gemini/gemini-1.5-pro`).
- `API Key` to your Gemini API key
## VertexAI - Google Cloud Platform Configs
@@ -27,4 +27,4 @@ VERTEXAI_LOCATION="<your-gcp-location>"
Then set the following in the OpenHands UI through the Settings:
- `LLM Provider` to `VertexAI`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced Options`, and enter it in `Custom Model` (e.g. vertex_ai/&lt;model-name&gt;).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. vertex_ai/&lt;model-name&gt;).

View File

@@ -8,7 +8,7 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
- `LLM Provider` to `Groq`
- `LLM Model` to the model you will be using. [Visit here to see the list of
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list, toggle
`Advanced Options`, and enter it in `Custom Model` (e.g. groq/&lt;model-name&gt; like `groq/llama3-70b-8192`).
`Advanced` options, and enter it in `Custom Model` (e.g. groq/&lt;model-name&gt; like `groq/llama3-70b-8192`).
- `API key` to your Groq API key. To find or create your Groq API Key, [see here](https://console.groq.com/keys).
@@ -17,7 +17,7 @@ models that Groq hosts](https://console.groq.com/docs/models). If the model is n
The Groq endpoint for chat completion is [mostly OpenAI-compatible](https://console.groq.com/docs/openai). Therefore, you can access Groq models as you
would access any OpenAI-compatible endpoint. In the OpenHands UI through the Settings:
1. Enable `Advanced Options`
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to the prefix `openai/` + the model you will be using (e.g. `openai/llama3-70b-8192`)
- `Base URL` to `https://api.groq.com/openai/v1`

View File

@@ -8,7 +8,7 @@ To use LiteLLM proxy with OpenHands, you need to:
1. Set up a LiteLLM proxy server (see [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/quick_start))
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
* Enable `Advanced Options`
* Enable `Advanced` options
* `Custom Model` to the prefix `litellm_proxy/` + the model you will be using (e.g. `litellm_proxy/anthropic.claude-3-5-sonnet-20241022-v2:0`)
* `Base URL` to your LiteLLM proxy URL (e.g. `https://your-litellm-proxy.com`)
* `API Key` to your LiteLLM proxy API key

View File

@@ -38,7 +38,7 @@ The following can be set in the OpenHands UI through the Settings:
- `LLM Provider`
- `LLM Model`
- `API Key`
- `Base URL` (through `Advanced Settings`)
- `Base URL` (through `Advanced` settings)
There are some settings that may be necessary for some LLMs/providers that cannot be set through the UI. Instead, these
can be set through environment variables passed to the [docker run command](/modules/usage/installation#start-the-app)

View File

@@ -8,7 +8,7 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
* `LLM Provider` to `OpenAI`
* `LLM Model` to the model you will be using.
[Visit here to see a full list of OpenAI models that LiteLLM supports.](https://docs.litellm.ai/docs/providers/openai#openai-chat-completion-models)
If the model is not in the list, toggle `Advanced Options`, and enter it in `Custom Model` (e.g. openai/&lt;model-name&gt; like `openai/gpt-4o`).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. openai/&lt;model-name&gt; like `openai/gpt-4o`).
* `API Key` to your OpenAI API key. To find or create your OpenAI Project API Key, [see here](https://platform.openai.com/api-keys).
## Using OpenAI-Compatible Endpoints
@@ -18,7 +18,7 @@ Just as for OpenAI Chat completions, we use LiteLLM for OpenAI-compatible endpoi
## Using an OpenAI Proxy
If you're using an OpenAI proxy, in the OpenHands UI through the Settings:
1. Enable `Advanced Options`
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to openai/&lt;model-name&gt; (e.g. `openai/gpt-4o` or openai/&lt;proxy-prefix&gt;/&lt;model-name&gt;)
- `Base URL` to the URL of your OpenAI proxy

View File

@@ -8,5 +8,5 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
* `LLM Provider` to `OpenRouter`
* `LLM Model` to the model you will be using.
[Visit here to see a full list of OpenRouter models](https://openrouter.ai/models).
If the model is not in the list, toggle `Advanced Options`, and enter it in `Custom Model` (e.g. openrouter/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. openrouter/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
* `API Key` to your OpenRouter API key.

View File

@@ -66,7 +66,7 @@ const sidebars: SidebarsConfig = {
},
{
type: 'doc',
label: 'Github Actions',
label: 'Github Action',
id: 'usage/how-to/github-action',
},
{

View File

@@ -23,6 +23,17 @@ export default function Home(): JSX.Element {
})}
>
<HomepageHeader />
<div style={{ textAlign: 'center', padding: '2rem' }}>
<br />
<h2>Most Popular Links</h2>
<ul style={{ listStyleType: 'none'}}>
<li><a href="/modules/usage/Installation">How to Run OpenHands</a></li>
<li><a href="/modules/usage/prompting/microagents-repo">Customizing OpenHands to a repository</a></li>
<li><a href="/modules/usage/how-to/github-action">Integrating OpenHands with Github</a></li>
<li><a href="/modules/usage/llms#model-recommendations">Recommended models to use</a></li>
<li><a href="/modules/usage/runtimes#connecting-to-your-filesystem">Connecting OpenHands to your filesystem</a></li>
</ul>
</div>
</Layout>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -78,12 +78,6 @@ describe("PaymentForm", () => {
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
});
it("should render the payment method link", async () => {
renderPaymentForm();
screen.getByTestId("payment-methods-link");
});
it("should disable the top-up button if the user enters an invalid amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
@@ -155,7 +149,7 @@ describe("PaymentForm", () => {
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "20"); // test assumes the minimum is 25
await user.type(topUpInput, "9"); // test assumes the minimum is 10
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);

View File

@@ -24,9 +24,15 @@ describe("amountIsValid", () => {
});
test("when an amount less than the minimum is passed", () => {
// test assumes the minimum is 25
expect(amountIsValid("24")).toBe(false);
expect(amountIsValid("24.99")).toBe(false);
// test assumes the minimum is 10
expect(amountIsValid("9")).toBe(false);
expect(amountIsValid("9.99")).toBe(false);
});
test("when an amount more than the maximum is passed", () => {
// test assumes the minimum is 25000
expect(amountIsValid("25001")).toBe(false);
expect(amountIsValid("25000.01")).toBe(false);
});
});
});

View File

@@ -55,7 +55,7 @@ export function AgentStatusBar() {
return (
<div className="flex flex-col items-center">
<div className="flex items-center bg-neutral-800 px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
<div className="flex items-center bg-base-secondary px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
<div
className={`w-2 h-2 rounded-full animate-pulse ${AGENT_STATUS_MAP[curAgentState].indicator}`}
/>

View File

@@ -73,7 +73,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<div
ref={ref}
data-testid="conversation-panel"
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl overflow-y-auto"
className="w-[350px] h-full border border-neutral-700 bg-base-secondary rounded-xl overflow-y-auto"
>
<div className="pt-4 px-4 flex items-center justify-between">
{isFetching && <LoadingSpinner size="small" />}

View File

@@ -19,7 +19,7 @@ export function FileExplorerHeader({
return (
<div
className={cn(
"sticky top-0 bg-neutral-800",
"sticky top-0 bg-base-secondary",
"flex items-center",
!isOpen ? "justify-center" : "justify-between",
)}

View File

@@ -54,7 +54,7 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
<div data-testid="file-explorer" className="relative h-full">
<div
className={cn(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
"bg-base-secondary h-full border-r-1 border-r-neutral-600 flex flex-col",
!isOpen ? "w-12" : "w-60",
)}
>

View File

@@ -5,7 +5,6 @@ import { cn } from "#/utils/utils";
import MoneyIcon from "#/icons/money.svg?react";
import { SettingsInput } from "../settings/settings-input";
import { BrandButton } from "../settings/brand-button";
import { HelpLink } from "../settings/help-link";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
@@ -80,13 +79,6 @@ export function PaymentForm() {
{isPending && <LoadingSpinner size="small" />}
</div>
</div>
<HelpLink
testId="payment-methods-link"
href="https://stripe.com/"
text="Manage payment methods on"
linkText="Stripe"
/>
</form>
);
}

View File

@@ -28,8 +28,8 @@ export function BrandButton({
onClick={onClick}
className={cn(
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
variant === "primary" && "bg-[#C9B974] text-[#0D0F11]",
variant === "secondary" && "border border-[#C9B974] text-[#C9B974]",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
className,
)}
>

View File

@@ -8,9 +8,7 @@ interface KeyStatusIconProps {
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
return (
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
<SuccessIcon
className={cn(isSet ? "text-[#A5E75E]" : "text-[#E76A5E]")}
/>
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
</span>
);
}

View File

@@ -1,3 +1,3 @@
export function OptionalTag() {
return <span className="text-xs text-[#B7BDC2]">(Optional)</span>;
return <span className="text-xs text-tertiary-alt">(Optional)</span>;
}

View File

@@ -38,12 +38,12 @@ export function SettingsDropdownInput({
isDisabled={isDisabled}
className="w-full"
classNames={{
popoverContent: "bg-[#454545] rounded-xl border border-[#717888]",
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
}}
inputProps={{
classNames: {
inputWrapper:
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
},
}}
>

View File

@@ -44,7 +44,7 @@ export function SettingsInput({
defaultValue={defaultValue}
placeholder={placeholder}
className={cn(
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-[#B7BDC2]",
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>

View File

@@ -40,7 +40,7 @@ export function SettingsSwitch({
<div className="flex items-center gap-1">
<span className="text-sm">{children}</span>
{isBeta && (
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-[#C9B974] px-1 rounded-full">
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-primary px-1 rounded-full">
Beta
</span>
)}

View File

@@ -11,14 +11,14 @@ export function StyledSwitchComponent({
<div
className={cn(
"w-12 h-6 rounded-xl flex items-center p-1.5 cursor-pointer",
isToggled && "justify-end bg-[#C9B974]",
!isToggled && "justify-start bg-[#1F2228] border border-[#B7BDC2]",
isToggled && "justify-end bg-primary",
!isToggled && "justify-start bg-[#1F2228] border border-tertiary-alt",
)}
>
<div
className={cn(
"bg-[#1F2228] w-3 h-3 rounded-xl",
isToggled ? "bg-[#1F2228]" : "bg-[#B7BDC2]",
isToggled ? "bg-[#1F2228]" : "bg-tertiary-alt",
)}
/>
</div>

View File

@@ -4,7 +4,7 @@ import { I18nKey } from "#/i18n/declaration";
export function BetaBadge() {
const { t } = useTranslation();
return (
<span className="text-[11px] leading-5 text-root-primary bg-neutral-400 px-1 rounded-xl">
<span className="text-[11px] leading-5 text-base bg-neutral-400 px-1 rounded-xl">
{t(I18nKey.BADGE$BETA)}
</span>
);

View File

@@ -23,7 +23,7 @@ export function Container({
return (
<div
className={clsx(
"bg-neutral-800 border border-neutral-600 rounded-xl flex flex-col",
"bg-base-secondary border border-neutral-600 rounded-xl flex flex-col",
className,
)}
>

View File

@@ -1,6 +1,6 @@
export function CountBadge({ count }: { count: number }) {
return (
<span className="text-[11px] leading-5 text-root-primary bg-neutral-400 px-1 rounded-xl">
<span className="text-[11px] leading-5 text-base bg-neutral-400 px-1 rounded-xl">
{count}
</span>
);

View File

@@ -17,10 +17,10 @@ export function NavTab({ to, label, icon, isBeta }: NavTabProps) {
to={to}
className={({ isActive }) =>
cn(
"px-2 border-b border-r border-neutral-600 bg-root-primary flex-1",
"px-2 border-b border-r border-neutral-600 bg-base flex-1",
"first-of-type:rounded-tl-xl last-of-type:rounded-tr-xl last-of-type:border-r-0",
"flex items-center gap-2",
isActive && "bg-root-secondary",
isActive && "bg-base-secondary",
)
}
>

View File

@@ -27,7 +27,7 @@ export function ActionTooltip({ type, onClick }: ActionTooltipProps) {
? t(I18nKey.ACTION$CONFIRM)
: t(I18nKey.ACTION$REJECT)
}
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
className="bg-neutral-700 rounded-full p-1 hover:bg-base-secondary"
onClick={onClick}
>
{type === "confirm" ? <ConfirmIcon /> : <RejectIcon />}

View File

@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
ariaLabel="All Hands Logo"
onClick={onClick}
>
<AllHandsLogo width={34} height={23} />
<AllHandsLogo width={34} height={34} />
</TooltipButton>
);
}

View File

@@ -18,7 +18,7 @@ export function HeroHeading() {
rel="noopener noreferrer"
target="_blank"
href="https://docs.all-hands.dev/modules/usage/getting-started"
className="text-hyperlink underline underline-offset-[3px]"
className="text-white underline underline-offset-[3px]"
>
{t(I18nKey.LANDING$START_HELP_LINK)}
</a>

View File

@@ -43,7 +43,7 @@ export function BaseModal({
backdrop="blur"
hideCloseButton
size="sm"
className="bg-neutral-900 rounded-lg"
className="bg-base rounded-lg"
>
<ModalContent className={contentClassName}>
{(closeModal) => (

View File

@@ -12,7 +12,7 @@ export function ModalBody({ testID, children, className }: ModalBodyProps) {
<div
data-testid={testID}
className={cn(
"bg-root-primary flex flex-col gap-6 items-center w-[384px] p-6 rounded-xl",
"bg-base flex flex-col gap-6 items-center w-[384px] p-6 rounded-xl",
className,
)}
>

View File

@@ -247,7 +247,7 @@ function SecurityInvariant() {
return (
<div className="flex flex-1 w-full h-full">
<div className="w-60 bg-neutral-800 border-r border-r-neutral-600 p-4 flex-shrink-0">
<div className="w-60 bg-base-secondary border-r border-r-neutral-600 p-4 flex-shrink-0">
<div className="text-center mb-2">
<InvariantLogoIcon className="mx-auto mb-1" />
<b>{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_LABEL)}</b>
@@ -285,7 +285,7 @@ function SecurityInvariant() {
</div>
</ul>
</div>
<div className="flex flex-col min-h-0 w-full overflow-y-auto bg-neutral-900">
<div className="flex flex-col min-h-0 w-full overflow-y-auto bg-base">
{sections[activeSection as SectionType]}
</div>
</div>

View File

@@ -84,12 +84,12 @@ export function ModelSelector({
defaultSelectedKey={selectedProvider ?? undefined}
selectedKey={selectedProvider}
classNames={{
popoverContent: "bg-[#454545] rounded-xl border border-[#717888]",
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
}}
inputProps={{
classNames: {
inputWrapper:
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
},
}}
>
@@ -135,12 +135,12 @@ export function ModelSelector({
selectedKey={selectedModel}
defaultSelectedKey={selectedModel ?? undefined}
classNames={{
popoverContent: "bg-[#454545] rounded-xl border border-[#717888]",
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
}}
inputProps={{
classNames: {
inputWrapper:
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
},
}}
>

View File

@@ -21,7 +21,7 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
<ModalBackdrop>
<div
data-testid="ai-config-modal"
className="bg-root-primary min-w-[384px] p-6 rounded-xl flex flex-col gap-2"
className="bg-base min-w-[384px] p-6 rounded-xl flex flex-col gap-2"
>
{aiConfigOptions.error && (
<p className="text-danger text-xs">{aiConfigOptions.error.message}</p>

View File

@@ -4,18 +4,19 @@
--bg-input: #393939;
--bg-workspace: #1f2228;
--border: #3c3c4a;
--text-editor-base: #9099AC;
--text-editor-active:#C4CBDA;
--bg-editor-sidebar: #24272E;
--bg-editor-active: #31343D;
--border-editor-sidebar: #3C3C4A;
background-color: var(--neutral-900) !important;
--text-editor-base: #9099ac;
--text-editor-active: #c4cbda;
--bg-editor-sidebar: #24272e;
--bg-editor-active: #31343d;
--border-editor-sidebar: #3c3c4a;
background-color: var(--base) !important;
--bg-neutral-muted: #afb8c133;
}
body {
margin: 0;
font-family: -apple-system, "SF Pro", BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
font-family:
-apple-system, "SF Pro", BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
@@ -23,8 +24,8 @@ body {
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
font-family:
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
.markdown-body code {
@@ -52,6 +53,7 @@ code {
text-align: left;
}
.markdown-body th, .markdown-body td {
.markdown-body th,
.markdown-body td {
padding: 0.1rem 1rem;
}

View File

@@ -29,7 +29,7 @@ function Home() {
const latestConversation = localStorage.getItem("latest_conversation_id");
return (
<div className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2">
<div className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2">
<HeroHeading />
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">

View File

@@ -75,7 +75,7 @@ function FileViewer() {
};
return (
<div className="flex h-full bg-neutral-900 relative">
<div className="flex h-full bg-base relative">
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
<div className="w-full h-full flex flex-col">
{selectedPath && (

View File

@@ -127,7 +127,7 @@ function AppContent() {
orientation={Orientation.HORIZONTAL}
className="grow h-full min-h-0 min-w-0"
initialSize={500}
firstClassName="rounded-xl overflow-hidden border border-neutral-600 bg-neutral-800"
firstClassName="rounded-xl overflow-hidden border border-neutral-600 bg-base-secondary"
secondClassName="flex flex-col overflow-hidden"
firstChild={<ChatInterface />}
secondChild={

View File

@@ -91,7 +91,7 @@ export default function MainApp() {
return (
<div
data-testid="root-layout"
className="bg-root-primary p-3 h-screen md:min-w-[1024px] overflow-x-hidden flex flex-col md:flex-row gap-3"
className="bg-base p-3 h-screen md:min-w-[1024px] overflow-x-hidden flex flex-col md:flex-row gap-3"
>
<Sidebar />

View File

@@ -388,7 +388,7 @@ function AccountSettings() {
</div>
</form>
<footer className="flex gap-6 p-6 justify-end border-t border-t-[#454545]">
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
type="button"
variant="secondary"
@@ -411,7 +411,7 @@ function AccountSettings() {
<ModalBackdrop>
<div
data-testid="reset-modal"
className="bg-root-primary p-4 rounded-xl flex flex-col gap-4"
className="bg-base p-4 rounded-xl flex flex-col gap-4"
>
<p>Are you sure you want to reset all settings?</p>
<div className="w-full flex gap-2">

View File

@@ -11,9 +11,9 @@ function SettingsScreen() {
return (
<main
data-testid="settings-screen"
className="bg-[#24272E] border border-[#454545] h-full rounded-xl flex flex-col"
className="bg-base-secondary border border-tertiary h-full rounded-xl flex flex-col"
>
<header className="px-3 py-1.5 border-b border-b-[#454545] flex items-center gap-2">
<header className="px-3 py-1.5 border-b border-b-tertiary flex items-center gap-2">
<SettingsIcon width={16} height={16} />
<h1 className="text-sm leading-6">Settings</h1>
</header>
@@ -21,7 +21,7 @@ function SettingsScreen() {
{isSaas && BILLING_SETTINGS() && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-[#454545]"
className="flex items-end gap-12 px-11 border-b border-tertiary"
>
{[
{ to: "/settings", text: "Account" },
@@ -34,7 +34,7 @@ function SettingsScreen() {
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-[#C9B974]",
isActive && "border-primary",
)
}
>

View File

@@ -1,10 +1,12 @@
const MINIMUM_AMOUNT = 25;
const MINIMUM_AMOUNT = 10;
const MAXIMUM_AMOUNT = 25_000;
export const amountIsValid = (amount: string) => {
const float = parseFloat(amount);
if (Number.isNaN(float)) return false;
if (float < 0) return false;
if (float < MINIMUM_AMOUNT) return false;
if (float > MAXIMUM_AMOUNT) return false;
return true;
};

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */
import { heroui } from "@heroui/react";
import typography from '@tailwindcss/typography';
import typography from "@tailwindcss/typography";
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
@@ -9,11 +9,13 @@ export default {
theme: {
extend: {
colors: {
'root-primary': '#171717',
'root-secondary': '#262626',
'hyperlink': '#007AFF',
'danger': '#EF3744',
'success': '#4CAF50',
primary: "#C9B974", // nice yellow
base: "#171717", // dark background (neutral-900)
"base-secondary": "#262626", // lighter background (neutral-800); also used for tooltips
danger: "#E76A5E",
success: "#A5E75E",
tertiary: "#454545", // gray, used for inputs
"tertiary-light": "#B7BDC2", // lighter gray, used for borders and placeholder text
},
},
},
@@ -32,8 +34,8 @@ export default {
colors: {
primary: "#4465DB",
},
}
}
},
},
}),
typography,
],

View File

@@ -22,7 +22,6 @@ from openhands.events.observation import (
CmdOutputObservation,
FileReadObservation,
FileWriteObservation,
NullObservation,
Observation,
)
from openhands.events.serialization.event import event_to_dict
@@ -109,7 +108,7 @@ class DummyAgent(Agent):
},
{
'action': AgentRejectAction(),
'observations': [NullObservation('')],
'observations': [AgentStateChangedObservation('', AgentState.REJECTED)],
},
{
'action': AgentFinishAction(

View File

@@ -6,11 +6,11 @@ from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.message import ImageContent, Message, TextContent
from openhands.core.utils import json
from openhands.events.action import Action
from openhands.events.event import Event
from openhands.events.serialization.action import action_from_dict
from openhands.events.serialization.event import event_to_memory
from openhands.io import json
from openhands.llm.llm import LLM

View File

@@ -29,8 +29,8 @@ from openhands.events.observation import (
AgentStateChangedObservation,
CmdOutputObservation,
FileEditObservation,
NullObservation,
)
from openhands.io import read_input, read_task
def display_message(message: str):
@@ -83,21 +83,6 @@ def display_event(event: Event, config: AppConfig):
display_confirmation(event.confirmation_state)
def read_input(config: AppConfig) -> str:
"""Read input from user based on config settings."""
if config.cli_multiline_input:
print('Enter your message (enter "/exit" on a new line to finish):')
lines = []
while True:
line = input('>> ').rstrip()
if line == '/exit': # finish input
break
lines.append(line)
return '\n'.join(lines)
else:
return input('>> ').rstrip()
async def main(loop: asyncio.AbstractEventLoop):
"""Runs the agent in CLI mode."""
@@ -105,7 +90,14 @@ async def main(loop: asyncio.AbstractEventLoop):
logger.setLevel(logging.WARNING)
config = setup_config_from_args(args)
# Load config from toml and override with command line arguments
config: AppConfig = setup_config_from_args(args)
# Read task from file, CLI args, or stdin
task_str = read_task(args, config.cli_multiline_input)
# If we have a task, create initial user action
initial_user_action = MessageAction(content=task_str) if task_str else None
sid = str(uuid4())
@@ -118,7 +110,9 @@ async def main(loop: asyncio.AbstractEventLoop):
async def prompt_for_next_task():
# Run input() in a thread pool to avoid blocking the event loop
next_message = await loop.run_in_executor(None, read_input, config)
next_message = await loop.run_in_executor(
None, read_input, config.cli_multiline_input
)
if not next_message.strip():
await prompt_for_next_task()
if next_message == 'exit':
@@ -143,19 +137,18 @@ async def main(loop: asyncio.AbstractEventLoop):
AgentState.FINISHED,
]:
await prompt_for_next_task()
if (
isinstance(event, NullObservation)
and controller.state.agent_state == AgentState.AWAITING_USER_CONFIRMATION
):
user_confirmed = await prompt_for_user_confirmation()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED), EventSource.USER
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED), EventSource.USER
)
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
user_confirmed = await prompt_for_user_confirmation()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
@@ -164,7 +157,12 @@ async def main(loop: asyncio.AbstractEventLoop):
await runtime.connect()
asyncio.create_task(prompt_for_next_task())
if initial_user_action:
# If there's an initial user action, enqueue it and do not prompt again
event_stream.add_event(initial_user_action, EventSource.USER)
else:
# Otherwise prompt for the user's first message right away
asyncio.create_task(prompt_for_next_task())
await run_agent_until_done(
controller, runtime, [AgentState.STOPPED, AgentState.ERROR]

View File

@@ -76,6 +76,7 @@ class AppConfig(BaseModel):
file_uploads_allowed_extensions: list[str] = Field(default_factory=lambda: ['.*'])
runloop_api_key: SecretStr | None = Field(default=None)
cli_multiline_input: bool = Field(default=False)
conversation_max_age_seconds: int = Field(default=864000) # 10 days in seconds
defaults_dict: ClassVar[dict] = {}

View File

@@ -102,3 +102,9 @@ class LLMConfig(BaseModel):
os.environ['OR_SITE_URL'] = self.openrouter_site_url
if self.openrouter_app_name:
os.environ['OR_APP_NAME'] = self.openrouter_app_name
# Assign an API version for Azure models
# While it doesn't seem required, the format supported by the API without version seems old and will likely break.
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/6777
if self.model.startswith('azure') and self.api_version is None:
self.api_version = '2024-08-01-preview'

View File

@@ -1,7 +1,6 @@
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Callable, Protocol
@@ -29,6 +28,7 @@ from openhands.events.event import Event
from openhands.events.observation import AgentStateChangedObservation
from openhands.events.serialization import event_from_dict
from openhands.events.serialization.event import event_to_trajectory
from openhands.io import read_input, read_task
from openhands.runtime.base import Runtime
@@ -41,32 +41,6 @@ class FakeUserResponseFunc(Protocol):
) -> str: ...
def read_task_from_file(file_path: str) -> str:
"""Read task from the specified file."""
with open(file_path, 'r', encoding='utf-8') as file:
return file.read()
def read_task_from_stdin() -> str:
"""Read task from stdin."""
return sys.stdin.read()
def read_input(config: AppConfig) -> str:
"""Read input from user based on config settings."""
if config.cli_multiline_input:
print('Enter your message (enter "/exit" on a new line to finish):')
lines = []
while True:
line = input('>> ').rstrip()
if line == '/exit': # finish input
break
lines.append(line)
return '\n'.join(lines)
else:
return input('>> ').rstrip()
async def run_controller(
config: AppConfig,
initial_user_action: Action,
@@ -139,7 +113,6 @@ async def run_controller(
assert isinstance(
initial_user_action, Action
), f'initial user actions must be an Action, got {type(initial_user_action)}'
# Logging
logger.debug(
f'Agent Controller Initialized: Running agent {agent.name}, model '
f'{agent.llm.config.model}, with actions: {initial_user_action}'
@@ -167,7 +140,7 @@ async def run_controller(
if exit_on_message:
message = '/exit'
elif fake_user_response_fn is None:
message = read_input(config)
message = read_input(config.cli_multiline_input)
else:
message = fake_user_response_fn(controller.get_state())
action = MessageAction(content=message)
@@ -268,28 +241,23 @@ def load_replay_log(trajectory_path: str) -> tuple[list[Event] | None, Action]:
if __name__ == '__main__':
args = parse_arguments()
config = setup_config_from_args(args)
config: AppConfig = setup_config_from_args(args)
# Determine the task
task_str = ''
if args.file:
task_str = read_task_from_file(args.file)
elif args.task:
task_str = args.task
elif not sys.stdin.isatty():
task_str = read_task_from_stdin()
# Read task from file, CLI args, or stdin
task_str = read_task(args, config.cli_multiline_input)
initial_user_action: Action = NullAction()
if config.replay_trajectory_path:
if task_str:
raise ValueError(
'User-specified task is not supported under trajectory replay mode'
)
elif task_str:
initial_user_action = MessageAction(content=task_str)
else:
if not task_str:
raise ValueError('No task provided. Please specify a task through -t, -f.')
# Create initial user action
initial_user_action: MessageAction = MessageAction(content=task_str)
# Set session name
session_name = args.name
sid = generate_sid(config, session_name)

View File

@@ -8,9 +8,9 @@ from functools import partial
from typing import Callable, Iterable
from openhands.core.logger import openhands_logger as logger
from openhands.core.utils import json
from openhands.events.event import Event, EventSource
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.io import json
from openhands.storage import FileStore
from openhands.storage.locations import (
get_conversation_dir,

View File

@@ -10,7 +10,9 @@ from openhands.events.observation import (
def get_pairs_from_events(events: list[Event]) -> list[tuple[Action, Observation]]:
"""Return the history as a list of tuples (action, observation)."""
"""Return the history as a list of tuples (action, observation).
This function is a compatibility function for evals reading and visualization working with old histories."""
tuples: list[tuple[Action, Observation]] = []
action_map: dict[int, Action] = {}
observation_map: dict[int, Observation] = {}

10
openhands/io/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
from openhands.io.io import read_input, read_task, read_task_from_file
from openhands.io.json import dumps, loads
__all__ = [
'read_input',
'read_task_from_file',
'read_task',
'dumps',
'loads',
]

40
openhands/io/io.py Normal file
View File

@@ -0,0 +1,40 @@
import argparse
import sys
def read_input(cli_multiline_input: bool = False) -> str:
"""Read input from user based on config settings."""
if cli_multiline_input:
print('Enter your message (enter "/exit" on a new line to finish):')
lines = []
while True:
line = input('>> ').rstrip()
if line == '/exit': # finish input
break
lines.append(line)
return '\n'.join(lines)
else:
return input('>> ').rstrip()
def read_task_from_file(file_path: str) -> str:
"""Read task from the specified file."""
with open(file_path, 'r', encoding='utf-8') as file:
return file.read()
def read_task(args: argparse.Namespace, cli_multiline_input: bool) -> str:
"""
Read the task from the CLI args, file, or stdin.
"""
# Determine the task
task_str = ''
if args.file:
task_str = read_task_from_file(args.file)
elif args.task:
task_str = args.task
elif not sys.stdin.isatty():
task_str = read_input(cli_multiline_input)
return task_str

View File

@@ -172,7 +172,7 @@ class LLM(RetryMixin, DebugMixin):
)
def wrapper(*args, **kwargs):
"""Wrapper for the litellm completion function. Logs the input and output of the completion function."""
from openhands.core.utils import json
from openhands.io import json
messages: list[dict[str, Any]] | dict[str, Any] = []
mock_function_calling = not self.is_function_calling_active()
@@ -369,7 +369,7 @@ class LLM(RetryMixin, DebugMixin):
# noinspection PyBroadException
except Exception:
pass
from openhands.core.utils import json
from openhands.io import json
logger.debug(f'Model info: {json.dumps(self.model_info, indent=2)}')

View File

@@ -57,22 +57,29 @@ class LLMSummarizingCondenser(RollingCondenser):
# Construct prompt for summarization
prompt = """You are maintaining state history for an LLM-based code agent. Track:
USER_CONTEXT: (Preserve essential user requirements, problem descriptions, and clarifications in concise form)
STATE: {File paths, function signatures, data structures}
TESTS: {Failing cases, error messages, outputs}
CHANGES: {Code edits, variable updates}
DEPS: {Dependencies, imports, external calls}
INTENT: {Why changes were made, acceptance criteria}
SKIP: {Git clones, build logs}
SUMMARIZE: {File listings}
MAX_LENGTH: Keep summaries under 1000 words
PRIORITIZE:
1. Capture key user requirements and constraints
2. Maintain critical problem context
3. Keep all sections concise
SKIP: {Git clones, build logs, file listings}
Example history format:
USER_CONTEXT: Fix FITS card float representation - "0.009125" becomes "0.009124999999999999" causing comment truncation. Use Python's str() when possible while maintaining FITS compliance.
STATE: mod_float() in card.py updated
TESTS: test_format() passed
CHANGES: str(val) replaces f"{val:.16G}"
DEPS: None modified
INTENT: Fix float precision overflow"""
INTENT: Fix precision while maintaining FITS compliance"""
prompt + '\n\n'

View File

@@ -429,14 +429,23 @@ async def resolve_issue(
# checkout the repo
repo_dir = os.path.join(output_dir, 'repo')
if not os.path.exists(repo_dir):
checkout_output = subprocess.check_output(
[
'git',
'clone',
issue_handler.get_clone_url(),
f'{output_dir}/repo',
]
).decode('utf-8')
# Configure Git LFS to skip downloading large files if requested
if os.getenv('GIT_LFS_SKIP_SMUDGE') == '1':
subprocess.check_output(['git', 'config', '--global', 'filter.lfs.smudge', 'git-lfs smudge --skip'])
subprocess.check_output(['git', 'config', '--global', 'filter.lfs.process', 'git-lfs filter-process --skip'])
# Build git clone command
clone_cmd = ['git', 'clone']
# Add --depth if requested
if depth := os.getenv('GIT_CLONE_DEPTH'):
clone_cmd.extend(['--depth', depth])
# Add repository URL and destination
clone_cmd.extend([issue_handler.get_clone_url(), f'{output_dir}/repo'])
# Execute git clone
checkout_output = subprocess.check_output(clone_cmd).decode('utf-8')
if 'fatal' in checkout_output:
raise RuntimeError(f'Failed to clone repository: {checkout_output}')

View File

@@ -254,6 +254,9 @@ class Runtime(FileEditRuntimeMixin):
# this might be unnecessary, since source should be set by the event stream when we're here
source = event.source if event.source else EventSource.AGENT
if isinstance(observation, NullObservation):
# don't add null observations to the event stream
return
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
def clone_repo(

View File

@@ -153,6 +153,12 @@ class RemoteRuntime(ActionExecutionClient):
return False
self.log('debug', f'Error while looking for remote runtime: {e}')
raise
except requests.exceptions.JSONDecodeError as e:
self.log(
'error',
f'Invalid JSON response from runtime API: {e}. URL: {self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}. Response: {response}',
)
raise
if status == 'running':
return True

View File

@@ -130,8 +130,9 @@ async def _create_new_conversation(
@app.post('/conversations')
async def new_conversation(request: Request, data: InitSessionRequest):
"""Initialize a new session or join an existing one.
After successful initialization, the client should connect to the WebSocket
using the returned conversation ID
using the returned conversation ID.
"""
logger.info('Initializing new conversation')
user_id = get_user_id(request)
@@ -188,10 +189,19 @@ async def search_conversations(
config, get_user_id(request)
)
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Filter out conversations older than max_age
now = datetime.now(timezone.utc)
max_age = config.conversation_max_age_seconds
filtered_results = [
conversation for conversation in conversation_metadata_result_set.results
if hasattr(conversation, 'created_at') and
(now - conversation.created_at.replace(tzinfo=timezone.utc)).total_seconds() <= max_age
]
conversation_ids = set(
conversation.conversation_id
for conversation in conversation_metadata_result_set.results
if hasattr(conversation, 'created_at')
for conversation in filtered_results
)
running_conversations = await conversation_manager.get_running_agent_loops(
get_user_id(request), set(conversation_ids)
@@ -202,7 +212,7 @@ async def search_conversations(
conversation=conversation,
is_running=conversation.conversation_id in running_conversations,
)
for conversation in conversation_metadata_result_set.results
for conversation in filtered_results
),
next_page_id=conversation_metadata_result_set.next_page_id,
)

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch
from openhands.core.cli import read_input
from openhands.core.config import AppConfig
from openhands.io import read_input
def test_single_line_input():
@@ -10,7 +10,7 @@ def test_single_line_input():
config.cli_multiline_input = False
with patch('builtins.input', return_value='hello world'):
result = read_input(config)
result = read_input(config.cli_multiline_input)
assert result == 'hello world'
@@ -23,5 +23,5 @@ def test_multiline_input():
mock_inputs = ['line 1', 'line 2', 'line 3', '/exit']
with patch('builtins.input', side_effect=mock_inputs):
result = read_input(config)
result = read_input(config.cli_multiline_input)
assert result == 'line 1\nline 2\nline 3'

View File

@@ -1,10 +1,11 @@
import json
from contextlib import contextmanager
from datetime import datetime
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.server.routes.manage_conversations import (
delete_conversation,
get_conversation,
@@ -30,8 +31,8 @@ def _patch_store():
'selected_repository': 'foobar',
'conversation_id': 'some_conversation_id',
'github_user_id': '12345',
'created_at': '2025-01-01T00:00:00',
'last_updated_at': '2025-01-01T00:01:00',
'created_at': '2025-01-01T00:00:00+00:00',
'last_updated_at': '2025-01-01T00:01:00+00:00',
}
),
)
@@ -49,22 +50,46 @@ def _patch_store():
@pytest.mark.asyncio
async def test_search_conversations():
with _patch_store():
result_set = await search_conversations(
MagicMock(state=MagicMock(github_token=''))
)
expected = ConversationInfoResultSet(
results=[
ConversationInfo(
conversation_id='some_conversation_id',
title='Some Conversation',
created_at=datetime.fromisoformat('2025-01-01T00:00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'),
status=ConversationStatus.STOPPED,
selected_repository='foobar',
)
]
)
assert result_set == expected
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
result_set = await search_conversations(
MagicMock(state=MagicMock(github_token=''))
)
expected = ConversationInfoResultSet(
results=[
ConversationInfo(
conversation_id='some_conversation_id',
title='Some Conversation',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
status=ConversationStatus.STOPPED,
selected_repository='foobar',
)
]
)
assert result_set == expected
@pytest.mark.asyncio
@@ -76,8 +101,8 @@ async def test_get_conversation():
expected = ConversationInfo(
conversation_id='some_conversation_id',
title='Some Conversation',
created_at=datetime.fromisoformat('2025-01-01T00:00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'),
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
status=ConversationStatus.STOPPED,
selected_repository='foobar',
)
@@ -109,8 +134,8 @@ async def test_update_conversation():
expected = ConversationInfo(
conversation_id='some_conversation_id',
title='New Title',
created_at=datetime.fromisoformat('2025-01-01T00:00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'),
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
status=ConversationStatus.STOPPED,
selected_repository='foobar',
)
@@ -120,11 +145,12 @@ async def test_update_conversation():
@pytest.mark.asyncio
async def test_delete_conversation():
with _patch_store():
await delete_conversation(
'some_conversation_id',
MagicMock(state=MagicMock(github_token='')),
)
conversation = await get_conversation(
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
)
assert conversation is None
with patch.object(DockerRuntime, 'delete', return_value=None):
await delete_conversation(
'some_conversation_id',
MagicMock(state=MagicMock(github_token='')),
)
conversation = await get_conversation(
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
)
assert conversation is None

View File

@@ -0,0 +1,91 @@
import os
import subprocess
import tempfile
from unittest import mock
import pytest
from openhands.resolver.resolve_issue import resolve_issue
from openhands.resolver.utils import Platform
@pytest.mark.asyncio
async def test_git_lfs_skip_smudge():
# Create a temporary directory for the test
with tempfile.TemporaryDirectory() as temp_dir:
# Mock environment variables
with mock.patch.dict(os.environ, {'GIT_LFS_SKIP_SMUDGE': '1'}):
# Mock subprocess.check_output to verify git config is called
with mock.patch('subprocess.check_output') as mock_check_output:
# Mock issue handler
mock_handler = mock.MagicMock()
mock_handler.get_clone_url.return_value = 'https://github.com/test/repo.git'
mock_handler.get_converted_issues.return_value = [mock.MagicMock()]
# Mock issue_handler_factory to return our mock handler
with mock.patch('openhands.resolver.resolve_issue.issue_handler_factory', return_value=mock_handler):
# Call resolve_issue with test parameters
await resolve_issue(
owner='test',
repo='repo',
token='token',
username='username',
platform=Platform.GITHUB,
max_iterations=1,
output_dir=temp_dir,
llm_config=mock.MagicMock(),
runtime_container_image=None,
prompt_template='',
issue_type='issue',
repo_instruction=None,
issue_number=1,
comment_id=None,
)
# Verify git config was called with correct parameters
mock_check_output.assert_any_call(['git', 'config', '--global', 'filter.lfs.smudge', 'git-lfs smudge --skip'])
mock_check_output.assert_any_call(['git', 'config', '--global', 'filter.lfs.process', 'git-lfs filter-process --skip'])
@pytest.mark.asyncio
async def test_git_clone_depth():
# Create a temporary directory for the test
with tempfile.TemporaryDirectory() as temp_dir:
# Mock environment variables
with mock.patch.dict(os.environ, {'GIT_CLONE_DEPTH': '1'}):
# Mock subprocess.check_output to verify git clone is called with --depth
with mock.patch('subprocess.check_output') as mock_check_output:
# Mock issue handler
mock_handler = mock.MagicMock()
mock_handler.get_clone_url.return_value = 'https://github.com/test/repo.git'
mock_handler.get_converted_issues.return_value = [mock.MagicMock()]
# Mock issue_handler_factory to return our mock handler
with mock.patch('openhands.resolver.resolve_issue.issue_handler_factory', return_value=mock_handler):
# Call resolve_issue with test parameters
await resolve_issue(
owner='test',
repo='repo',
token='token',
username='username',
platform=Platform.GITHUB,
max_iterations=1,
output_dir=temp_dir,
llm_config=mock.MagicMock(),
runtime_container_image=None,
prompt_template='',
issue_type='issue',
repo_instruction=None,
issue_number=1,
comment_id=None,
)
# Verify git clone was called with --depth
mock_check_output.assert_any_call([
'git',
'clone',
'--depth',
'1',
'https://github.com/test/repo.git',
f'{temp_dir}/repo',
])

View File

@@ -1,7 +1,7 @@
from datetime import datetime
from openhands.core.utils import json
from openhands.events.action import MessageAction
from openhands.io import json
def test_event_serialization_deserialization():

View File

@@ -3,7 +3,7 @@ from datetime import datetime
import psutil
from openhands.core.utils.json import dumps
from openhands.io.json import dumps
def get_memory_usage():

View File

@@ -2,11 +2,11 @@ import pytest
from openhands.agenthub.micro.agent import parse_response as parse_response_micro
from openhands.core.exceptions import LLMResponseError
from openhands.core.utils.json import loads as custom_loads
from openhands.events.action import (
FileWriteAction,
MessageAction,
)
from openhands.io import loads as custom_loads
@pytest.mark.parametrize(