Compare commits

...

14 Commits

Author SHA1 Message Date
Engel Nyst
feab5024d0 Clean out debug print (#7666) 2025-04-02 14:17:25 -04:00
Robert Brennan
ab0ab6fc6a dont return asterisks for api key (#7654)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-02 12:46:22 -04:00
mamoodi
0a4307a0de Release 0.31.0 2025-04-02 08:45:26 -04:00
Xingyao Wang
6851215410 fix inline imports in LLM-generated title (#7642) 2025-04-01 22:15:12 +00:00
Xingyao Wang
b91acbb14a (feat): stop auto generate title in converstaion GET (#7638) 2025-04-01 17:19:48 -04:00
Ray Myers
0045d46d9d Update dockerfile to remove vulnerabilities in Debian 12 (#7630) 2025-04-01 14:09:01 -05:00
Rohit Malhotra
a2e9e23569 (Chore): Rename GitHub instances to Git (#7625)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-01 16:53:37 +00:00
dependabot[bot]
8e61f8e688 chore(deps): bump the version-all group with 7 updates (#7624)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:44:58 +02:00
Robert Brennan
b4bc08e70c Fix "no query provided" (#7564) 2025-04-01 12:31:46 -04:00
Ray Myers
242664f81b fix - Remove unmatched tool calls for Claude (#7597) 2025-04-01 10:45:04 -05:00
VS
4a2045ecca [Fix] Github Action possible command injection via unsanitised user input like review body and gti (#7569)
Co-authored-by: Vasyl Spachynskyi <vasyl.spachynskyi@dataart.com>
2025-04-01 23:26:46 +08:00
Rohit Malhotra
89bfbfad59 [Feat]: Gitlab p2 - let user add PAT via FE (#7125) 2025-04-01 11:23:58 -04:00
Bill Yuchen Lin
7488d1d0cb add support of DOCKER_HOST_ADDR (#7593) 2025-04-01 14:54:49 +00:00
Rohit Malhotra
9adfcede31 (Hotfix): Track reason for Error AgentState (#7584)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-31 21:24:42 +00:00
94 changed files with 1205 additions and 569 deletions

View File

@@ -145,13 +145,15 @@ jobs:
fi
- name: Set environment variables
env:
REVIEW_BODY: ${{ github.event.review.body || '' }}
run: |
# Handle pull request events first
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle pull request review events
elif [ -n "${{ github.event.review.body }}" ]; then
elif [ -n "$REVIEW_BODY" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle issue comment events that reference a PR
@@ -164,7 +166,7 @@ jobs:
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
fi
if [ -n "${{ github.event.review.body }}" ]; then
if [ -n "$REVIEW_BODY" ]; then
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
else
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV

View File

@@ -19,9 +19,10 @@ jobs:
ref: ${{ github.head_ref }}
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
PR_BRANCH="${{ github.head_ref }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"

View File

@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.30-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.31-nikolaik`
## Develop inside Docker container

View File

@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.31
```
> [!WARNING]

View File

@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.30-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.31-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.30
docker.all-hands.dev/all-hands-ai/openhands:0.31
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.cli
```

View File

@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.30
docker.all-hands.dev/all-hands-ai/openhands:0.31
```

View File

@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```

View File

@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.31
```
Você encontrará o OpenHands em execução em http://localhost:3000!

View File

@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.30
docker.all-hands.dev/all-hands-ai/openhands:0.31
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.31
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -521,6 +521,11 @@ def compatibility_for_eval_history_pairs(
def is_fatal_evaluation_error(error: str | None) -> bool:
"""
The AgentController class overrides last error for certain exceptions
We want to ensure those exeption do not overlap with fatal exceptions defined here
This is because we do a comparisino against the stringified error
"""
if not error:
return False

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import { useAuth } from "#/context/auth-context";
@@ -21,21 +21,34 @@ vi.mock("#/context/auth-context", () => ({
describe("ActionSuggestions", () => {
// Setup mocks for each test
vi.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
(useAuth as any).mockReturnValue({
githubTokenIsSet: true,
});
(useAuth as any).mockReturnValue({
providersAreSet: true,
});
(useSelector as any).mockReturnValue({
selectedRepository: "test-repo",
(useSelector as any).mockReturnValue({
selectedRepository: "test-repo",
});
});
it("should render both GitHub buttons when GitHub token is set and repository is selected", () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
const pushButton = screen.getByRole("button", { name: "Push to Branch" });
const prButton = screen.getByRole("button", { name: "Push & Create PR" });
// Find all buttons with data-testid="suggestion"
const buttons = screen.getAllByTestId("suggestion");
// Check if we have at least 2 buttons
expect(buttons.length).toBeGreaterThanOrEqual(2);
// Check if the buttons contain the expected text
const pushButton = buttons.find((button) =>
button.textContent?.includes("Push to Branch"),
);
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(pushButton).toBeInTheDocument();
expect(prButton).toBeInTheDocument();
@@ -43,13 +56,12 @@ describe("ActionSuggestions", () => {
it("should not render buttons when GitHub token is not set", () => {
(useAuth as any).mockReturnValue({
githubTokenIsSet: false,
providersAreSet: false,
});
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
});
it("should not render buttons when no repository is selected", () => {
@@ -59,17 +71,20 @@ describe("ActionSuggestions", () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
});
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
// This test verifies that the prompts are different in the component
const component = render(<ActionSuggestions onSuggestionsClick={() => {}} />);
const component = render(
<ActionSuggestions onSuggestionsClick={() => {}} />,
);
// Get the component instance to access the internal values
const pushBranchPrompt = "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
const createPRPrompt = "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
const pushBranchPrompt =
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
const createPRPrompt =
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
// Verify the prompts are different
expect(pushBranchPrompt).not.toEqual(createPRPrompt);

View File

@@ -1,16 +1,17 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
import { GitRepositorySelector } from "#/components/features/git/git-repo-selector";
import OpenHands from "#/api/open-hands";
import { Provider } from "#/types/settings";
describe("GitHubRepositorySelector", () => {
describe("GitRepositorySelector", () => {
const onInputChangeMock = vi.fn();
const onSelectMock = vi.fn();
it("should render the search input", () => {
renderWithProviders(
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -19,7 +20,7 @@ describe("GitHubRepositorySelector", () => {
);
expect(
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"),
).toBeInTheDocument();
});
@@ -37,7 +38,7 @@ describe("GitHubRepositorySelector", () => {
});
renderWithProviders(
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -53,23 +54,25 @@ describe("GitHubRepositorySelector", () => {
{
id: 1,
full_name: "test/repo1",
git_provider: "github" as Provider,
stargazers_count: 100,
},
{
id: 2,
full_name: "test/repo2",
git_provider: "github" as Provider,
stargazers_count: 200,
},
];
const searchPublicRepositoriesSpy = vi.spyOn(
OpenHands,
"searchGitHubRepositories",
"searchGitRepositories",
);
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
renderWithProviders(
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}

View File

@@ -18,7 +18,7 @@ describe("useSaveSettings", () => {
),
});
result.current.mutate({ LLM_API_KEY: "" });
result.current.mutate({ llm_api_key: "" });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -27,7 +27,7 @@ describe("useSaveSettings", () => {
);
});
result.current.mutate({ LLM_API_KEY: null });
result.current.mutate({ llm_api_key: null });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -10,12 +10,18 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
import { Provider } from "#/types/settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
await user.click(advancedSwitch);
};
const mock_provider_tokens_are_set: Record<Provider, boolean> = {
github: true,
gitlab: false,
};
describe("Settings Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
@@ -59,7 +65,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
screen.getByText("LLM Settings");
screen.getByText("GitHub Settings");
screen.getByText("Git Provider Settings");
screen.getByText("Additional Settings");
screen.getByText("Reset to defaults");
screen.getByText("Save Changes");
@@ -94,7 +100,6 @@ describe("Settings Screen", () => {
it.skip("should render an indicator if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
});
renderSettingsScreen();
@@ -115,7 +120,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
provider_tokens_set: mock_provider_tokens_are_set,
});
renderSettingsScreen();
@@ -129,7 +134,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
provider_tokens_set: mock_provider_tokens_are_set,
});
renderSettingsScreen();
@@ -145,27 +150,26 @@ describe("Settings Screen", () => {
}
});
it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => {
it("should render a disabled 'Disconnect Tokens' button if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect from GitHub");
const button = await screen.findByText("Disconnect Tokens");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => {
it("should render an enabled 'Disconnect Tokens' button if any Git tokens are set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
provider_tokens_set: mock_provider_tokens_are_set,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect from GitHub");
const button = await screen.findByText("Disconnect Tokens");
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
@@ -174,17 +178,17 @@ describe("Settings Screen", () => {
expect(input).toBeInTheDocument();
});
it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => {
it("should logout the user when the 'Disconnect Tokens' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
provider_tokens_set: mock_provider_tokens_are_set,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect from GitHub");
const button = await screen.findByText("Disconnect Tokens");
await user.click(button);
expect(handleLogoutMock).toHaveBeenCalled();
@@ -249,7 +253,6 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
});
saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
@@ -392,7 +395,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
llm_api_key_set: true,
});
renderSettingsScreen();
@@ -413,7 +416,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
llm_api_key_set: true,
});
renderSettingsScreen();
@@ -707,7 +710,6 @@ describe("Settings Screen", () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
github_token_is_set: true,
user_consents_to_analytics: true,
llm_base_url: "https://test.com",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
@@ -719,7 +721,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument();
expect(screen.getByText("Disconnect Tokens")).toBeInTheDocument();
expect(screen.getByTestId("enable-analytics-switch")).toBeChecked();
expect(screen.getByTestId("advanced-settings-switch")).toBeChecked();
expect(screen.getByTestId("base-url-input")).toHaveValue(
@@ -760,7 +762,6 @@ describe("Settings Screen", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "", // empty because it's not set previously
provider_tokens: undefined,
language: "no",
}),
);
@@ -797,7 +798,6 @@ describe("Settings Screen", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: undefined,
llm_api_key: "", // empty because it's not set previously
llm_model: "openai/gpt-4o",
}),
@@ -846,11 +846,17 @@ describe("Settings Screen", () => {
// Wait for the mutation to complete and the modal to be removed
await waitFor(() => {
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("llm-custom-model-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("security-analyzer-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("enable-confirmation-mode-switch")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
@@ -965,7 +971,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
llm_api_key_set: true,
});
renderSettingsScreen();

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.30.1",
"version": "0.31.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.30.1",
"version": "0.31.0",
"dependencies": {
"@heroui/react": "2.7.5",
"@monaco-editor/react": "^4.7.0-rc.0",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.30.1",
"version": "0.31.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -1,3 +1,4 @@
import { GitRepository } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { openHands } from "./open-hands-axios";
@@ -14,8 +15,8 @@ export const retrieveGitHubAppRepositories = async (
per_page = 30,
) => {
const installationId = installations[installationIndex];
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
sort: "pushed",
@@ -53,12 +54,9 @@ export const retrieveGitHubAppRepositories = async (
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
export const retrieveGitHubUserRepositories = async (
page = 1,
per_page = 30,
) => {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => {
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
sort: "pushed",
@@ -68,6 +66,7 @@ export const retrieveGitHubUserRepositories = async (
},
);
// Check if any provider has more results
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header

View File

@@ -3,22 +3,3 @@ import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
export const setAuthTokenHeader = (token: string) => {
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
};
export const setGitHubTokenHeader = (token: string) => {
openHands.defaults.headers.common["X-GitHub-Token"] = token;
};
export const removeAuthTokenHeader = () => {
if (openHands.defaults.headers.common.Authorization) {
delete openHands.defaults.headers.common.Authorization;
}
};
export const removeGitHubTokenHeader = () => {
if (openHands.defaults.headers.common["X-GitHub-Token"]) {
delete openHands.defaults.headers.common["X-GitHub-Token"];
}
};

View File

@@ -14,6 +14,7 @@ import {
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
class OpenHands {
/**
@@ -305,12 +306,12 @@ class OpenHands {
return data.credits;
}
static async getGitHubUser(): Promise<GitHubUser> {
const response = await openHands.get<GitHubUser>("/api/github/user");
static async getGitUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
const { data } = response;
const user: GitHubUser = {
const user: GitUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
@@ -323,16 +324,16 @@ class OpenHands {
}
static async getGitHubUserInstallationIds(): Promise<number[]> {
const response = await openHands.get<number[]>("/api/github/installations");
const response = await openHands.get<number[]>("/api/user/installations");
return response.data;
}
static async searchGitHubRepositories(
static async searchGitRepositories(
query: string,
per_page = 5,
): Promise<GitHubRepository[]> {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/search/repositories",
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
query,

View File

@@ -1,6 +0,0 @@
import { ErrorResponse, FileUploadSuccessResponse } from "./open-hands.types";
export const isOpenHandsErrorResponse = (
data: ErrorResponse | FileUploadSuccessResponse,
): data is ErrorResponse =>
typeof data === "object" && data !== null && "error" in data;

View File

@@ -12,24 +12,43 @@ interface ActionSuggestionsProps {
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { githubTokenIsSet } = useAuth();
const { providersAreSet } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const isGitLab =
selectedRepository !== null &&
selectedRepository.git_provider &&
selectedRepository.git_provider.toLowerCase() === "gitlab";
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
const terms = {
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${
isGitLab ? "GitLab" : "GitHub"
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${
isGitLab ? "GitLab" : "GitHub"
} and open a ${pr}. Please create a meaningful branch name that describes the changes.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};
return (
<div className="flex flex-col gap-2 mb-2">
{githubTokenIsSet && selectedRepository && (
{providersAreSet && selectedRepository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.",
value: terms.pushToBranch,
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
@@ -38,9 +57,8 @@ export function ActionSuggestions({
/>
<SuggestionItem
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.",
label: `Push & Create ${terms.prShort}`,
value: terms.createPR,
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
@@ -52,9 +70,8 @@ export function ActionSuggestions({
) : (
<SuggestionItem
suggestion={{
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
label: `Push changes to ${terms.prShort}`,
value: terms.pushToPR,
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");

View File

@@ -5,7 +5,7 @@ import { setInitialPrompt } from "#/state/initial-query-slice";
const INITIAL_PROMPT = "";
export function CodeNotInGitHubLink() {
export function CodeNotInGitLink() {
const dispatch = useDispatch();
const { mutate: createConversation } = useCreateConversation();
@@ -17,7 +17,7 @@ export function CodeNotInGitHubLink() {
return (
<div className="text-xs text-neutral-400">
Code not in GitHub?{" "}
Code not in Git?{" "}
<span
onClick={handleStartFromScratch}
className="underline cursor-pointer"

View File

@@ -0,0 +1,203 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
Spinner,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { GitRepository } from "#/types/git";
import { Provider, ProviderOptions } from "#/types/settings";
interface GitRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitRepository[];
publicRepositories: GitRepository[];
isLoading?: boolean;
}
export function GitRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
isLoading = false,
}: GitRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
// Group repositories by provider
const groupedUserRepos = userRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const groupedPublicRepos = publicRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = isLoading ? (
<div className="flex items-center justify-center py-2">
<Spinner size="sm" className="mr-2" />
<span>{t(I18nKey.GITHUB$LOADING_REPOSITORIES)}</span>
</div>
) : (
t(I18nKey.GITHUB$NO_RESULTS)
);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="Git Repository"
placeholder={t(I18nKey.LANDING$SELECT_GIT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
endContent: isLoading ? <Spinner size="sm" /> : undefined,
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const sanitizedInput = sanitizeQuery(inputValue);
const repo = allRepositories.find((r) => r.full_name === textValue);
if (!repo) return false;
const provider = repo.git_provider?.toLowerCase() as Provider;
const providerKeys = Object.keys(ProviderOptions) as Provider[];
// If input is exactly "git", show repos from any git-based provider
if (sanitizedInput === "git") {
return providerKeys.includes(provider);
}
// Provider based typeahead
for (const p of providerKeys) {
if (p.startsWith(sanitizedInput)) {
return provider === p;
}
}
// Default case: check if the repository name matches the input
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{Object.entries(groupedUserRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`user-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$YOUR_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
{Object.entries(groupedPublicRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`public-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$PUBLIC_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
</Autocomplete>
);
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { GitRepositorySelector } from "./git-repo-selector";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
@@ -11,29 +11,35 @@ import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubErrorReponse, GitUser } from "#/types/git";
interface GitHubRepositoriesSuggestionBoxProps {
interface GitRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitHubUser | null;
user: GitHubErrorReponse | GitUser | null;
}
export function GitHubRepositoriesSuggestionBox({
export function GitRepositoriesSuggestionBox({
handleSubmit,
gitHubAuthUrl,
user,
}: GitHubRepositoriesSuggestionBoxProps) {
}: GitRepositoriesSuggestionBoxProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: appRepositories } = useAppRepositories();
const { data: userRepositories } = useUserRepositories();
const { data: searchedRepos } = useSearchRepositories(
sanitizeQuery(debouncedSearchQuery),
);
const { data: appRepositories, isLoading: isAppReposLoading } =
useAppRepositories();
const { data: userRepositories, isLoading: isUserReposLoading } =
useUserRepositories();
const { data: searchedRepos, isLoading: isSearchReposLoading } =
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
const isLoading =
isAppReposLoading || isUserReposLoading || isSearchReposLoading;
const repositories =
userRepositories?.pages.flatMap((page) => page.data) ||
@@ -55,11 +61,12 @@ export function GitHubRepositoriesSuggestionBox({
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={repositories}
isLoading={isLoading}
/>
) : (
<BrandButton

View File

@@ -1,129 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
interface GitHubRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitHubRepository[];
publicRepositories: GitHubRepository[];
}
export function GitHubRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
}: GitHubRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitHubRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = t(I18nKey.GITHUB$NO_RESULTS);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="GitHub Repository"
placeholder={t(I18nKey.LANDING$SELECT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) =>
!inputValue ||
sanitizeQuery(textValue).includes(sanitizeQuery(inputValue))
}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{userRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$YOUR_REPOS)}>
{userRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
)}
{publicRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$PUBLIC_REPOS)}>
{publicRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
)}
</Autocomplete>
);
}

View File

@@ -3,7 +3,7 @@ import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { NavLink, useLocation } from "react-router";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitUser } from "#/hooks/query/use-git-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
@@ -26,7 +26,7 @@ export function Sidebar() {
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitHubUser();
const user = useGitUser();
const { data: config } = useConfig();
const {
data: settings,

View File

@@ -50,7 +50,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
});
@@ -74,7 +74,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
const isLLMKeySet = settings.LLM_API_KEY === "**********";
const isLLMKeySet = settings.LLM_API_KEY_SET;
return (
<div>

View File

@@ -1,27 +1,37 @@
import React from "react";
import { Provider } from "#/types/settings";
interface AuthContextType {
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => void;
providerTokensSet: Provider[];
setProviderTokensSet: (tokens: Provider[]) => void;
providersAreSet: boolean;
setProvidersAreSet: (status: boolean) => void;
}
interface AuthContextProps extends React.PropsWithChildren {
initialGithubTokenIsSet?: boolean;
initialProviderTokens?: Provider[];
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(
!!initialGithubTokenIsSet,
function AuthProvider({
children,
initialProviderTokens = [],
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
const value = React.useMemo(
() => ({
githubTokenIsSet,
setGitHubTokenIsSet,
providerTokensSet,
setProviderTokensSet,
providersAreSet,
setProvidersAreSet,
}),
[githubTokenIsSet, setGitHubTokenIsSet],
[providerTokensSet],
);
return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -17,19 +17,10 @@ export const useCreateConversation = () => {
return useMutation({
mutationFn: async (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
files.length === 0 &&
!replayJson
) {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
selectedRepository || undefined,
selectedRepository?.full_name || undefined,
variables.q,
files,
replayJson || undefined,

View File

@@ -4,7 +4,7 @@ import { useAuth } from "#/context/auth-context";
import { useConfig } from "../query/use-config";
export const useLogout = () => {
const { setGitHubTokenIsSet } = useAuth();
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
const queryClient = useQueryClient();
const { data: config } = useConfig();
@@ -20,7 +20,8 @@ export const useLogout = () => {
queryClient.removeQueries({ queryKey: ["settings"] });
// Update token state - this will trigger a settings refetch since it's part of the query key
setGitHubTokenIsSet(false);
setProviderTokensSet([]);
setProvidersAreSet(false);
},
});
};

View File

@@ -21,14 +21,14 @@ const saveSettingsMutationFn = async (
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
llm_api_key:
settings.LLM_API_KEY === ""
settings.llm_api_key === ""
? ""
: settings.LLM_API_KEY?.trim() || undefined,
: settings.llm_api_key?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens: settings.provider_tokens,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens: settings.provider_tokens,
};
await OpenHands.saveSettings(apiSettings);

View File

@@ -5,13 +5,13 @@ import { useAuth } from "#/context/auth-context";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { githubTokenIsSet } = useAuth();
const { providersAreSet } = useAuth();
return useQuery({
queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
queryKey: ["installations", providersAreSet, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
githubTokenIsSet &&
providersAreSet &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -1,17 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubAppRepositories } from "#/api/github";
import { retrieveGitHubAppRepositories } from "#/api/git";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useAppRepositories = () => {
const { githubTokenIsSet } = useAuth();
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations();
const repos = useInfiniteQuery({
queryKey: ["repositories", githubTokenIsSet, installations],
queryKey: ["repositories", providersAreSet, installations],
queryFn: async ({
pageParam,
}: {
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
return null;
},
enabled:
githubTokenIsSet &&
providersAreSet &&
Array.isArray(installations) &&
installations.length > 0 &&
config?.APP_MODE === "saas",

View File

@@ -6,16 +6,16 @@ import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useLogout } from "../mutation/use-logout";
export const useGitHubUser = () => {
const { githubTokenIsSet } = useAuth();
export const useGitUser = () => {
const { providersAreSet, providerTokensSet } = useAuth();
const { mutateAsync: logout } = useLogout();
const { data: config } = useConfig();
const user = useQuery({
queryKey: ["user", githubTokenIsSet],
queryFn: OpenHands.getGitHubUser,
enabled: githubTokenIsSet && !!config?.APP_MODE,
queryKey: ["user", providerTokensSet],
queryFn: OpenHands.getGitUser,
enabled: providersAreSet && !!config?.APP_MODE,
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useIsAuthed = () => {
const { githubTokenIsSet } = useAuth();
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
return useQuery({
queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
queryKey: ["user", "authenticated", providersAreSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode,
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -4,7 +4,7 @@ import OpenHands from "#/api/open-hands";
export function useSearchRepositories(query: string) {
return useQuery({
queryKey: ["repositories", query],
queryFn: () => OpenHands.searchGitHubRepositories(query, 3),
queryFn: () => OpenHands.searchGitRepositories(query, 3),
enabled: !!query,
select: (data) => data.map((repo) => ({ ...repo, is_public: true })),
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -15,9 +15,9 @@ const getSettingsQueryFn = async () => {
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: apiSettings.llm_api_key,
LLM_API_KEY_SET: apiSettings.llm_api_key_set,
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
@@ -27,10 +27,11 @@ const getSettingsQueryFn = async () => {
};
export const useSettings = () => {
const { setGitHubTokenIsSet, githubTokenIsSet } = useAuth();
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const query = useQuery({
queryKey: ["settings", githubTokenIsSet],
queryKey: ["settings", providerTokensSet],
queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
@@ -44,14 +45,24 @@ export const useSettings = () => {
});
React.useEffect(() => {
if (query.isFetched && query.data?.LLM_API_KEY) {
if (query.isFetched && query.data?.LLM_API_KEY_SET) {
posthog.capture("user_activated");
}
}, [query.data?.LLM_API_KEY, query.isFetched]);
}, [query.data?.LLM_API_KEY_SET, query.isFetched]);
React.useEffect(() => {
if (query.isFetched) setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
if (query.data?.PROVIDER_TOKENS_SET) {
const providers = query.data.PROVIDER_TOKENS_SET;
const setProviders = (
Object.keys(providers) as Array<keyof typeof providers>
).filter((key) => providers[key]);
setProviderTokensSet(setProviders);
const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
(value) => value,
);
setProvidersAreSet(atLeastOneSet);
}
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because

View File

@@ -1,20 +1,20 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubUserRepositories } from "#/api/github";
import { retrieveUserGitRepositories } from "#/api/git";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useUserRepositories = () => {
const { githubTokenIsSet } = useAuth();
const { providerTokensSet, providersAreSet } = useAuth();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", githubTokenIsSet],
queryKey: ["repositories", providerTokensSet],
queryFn: async ({ pageParam }) =>
retrieveGitHubUserRepositories(pageParam, 100),
retrieveUserGitRepositories(pageParam, 100),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: githubTokenIsSet && config?.APP_MODE === "oss",
enabled: providersAreSet && config?.APP_MODE === "oss",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -9,15 +9,15 @@ interface UseGitHubAuthUrlConfig {
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
const { githubTokenIsSet } = useAuth();
const { providersAreSet } = useAuth();
return React.useMemo(() => {
if (config.appMode === "saas" && !githubTokenIsSet)
if (config.appMode === "saas" && !providersAreSet)
return generateGitHubAuthUrl(
config.gitHubClientId || "",
new URL(window.location.href),
);
return null;
}, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
}, [providersAreSet, config.appMode, config.gitHubClientId]);
};

View File

@@ -151,6 +151,7 @@ export enum I18nKey {
LANDING$CHANGE_PROMPT = "LANDING$CHANGE_PROMPT",
GITHUB$CONNECT = "GITHUB$CONNECT",
GITHUB$NO_RESULTS = "GITHUB$NO_RESULTS",
GITHUB$LOADING_REPOSITORIES = "GITHUB$LOADING_REPOSITORIES",
GITHUB$ADD_MORE_REPOS = "GITHUB$ADD_MORE_REPOS",
GITHUB$YOUR_REPOS = "GITHUB$YOUR_REPOS",
GITHUB$PUBLIC_REPOS = "GITHUB$PUBLIC_REPOS",
@@ -305,7 +306,7 @@ export enum I18nKey {
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS",
LANDING$SELECT_REPO = "LANDING$SELECT_REPO",
LANDING$SELECT_GIT_REPO = "LANDING$SELECT_GIT_REPO",
BUTTON$SEND = "BUTTON$SEND",
STATUS$WAITING_FOR_CLIENT = "STATUS$WAITING_FOR_CLIENT",
SUGGESTIONS$WHAT_TO_BUILD = "SUGGESTIONS$WHAT_TO_BUILD",

View File

@@ -2078,6 +2078,7 @@
"tr": "Ajan hız sınırına ulaştı",
"ja": "エージェントがレート制限中"
},
"CHAT_INTERFACE$AGENT_PAUSED_MESSAGE": {
"en": "Agent has paused.",
"de": "Agent pausiert.",
@@ -2238,7 +2239,19 @@
"es": "No se encontraron resultados.",
"de": "Keine Ergebnisse gefunden.",
"it": "Nessun risultato trovato.",
"pt": "Nenhum resultado encontrado.",
"pt": "Nenhum resultado encontrado."
},
"GITHUB$LOADING_REPOSITORIES": {
"en": "Loading repositories...",
"ja": "リポジトリを読み込み中...",
"zh-CN": "正在加载仓库...",
"zh-TW": "正在加載倉庫...",
"ko-KR": "저장소 로딩 중...",
"fr": "Chargement des dépôts...",
"es": "Cargando repositorios...",
"de": "Lade Repositories...",
"it": "Caricamento dei repository...",
"pt": "Carregando repositórios...",
"ar": "لم يتم العثور على نتائج.",
"no": "Ingen resultater funnet.",
"tr": "Sonuç bulunamadı"
@@ -4553,19 +4566,19 @@
"no": "Ingen samtaler funnet",
"tr": "Konuşma yok"
},
"LANDING$SELECT_REPO": {
"en": "Select a GitHub project",
"ja": "GitHubプロジェクトを選択",
"zh-CN": "选择GitHub项目",
"zh-TW": "選擇 GitHub 專案",
"ko-KR": "GitHub 프로젝트 선택",
"fr": "Sélectionner un projet GitHub",
"es": "Seleccionar un proyecto de GitHub",
"de": "Ein GitHub-Projekt auswählen",
"it": "Seleziona un progetto GitHub",
"pt": "Selecionar um projeto do GitHub",
"ar": "اختر مشروع GitHub",
"no": "Velg et GitHub-prosjekt",
"LANDING$SELECT_GIT_REPO": {
"en": "Select a Git project",
"ja": "Gitプロジェクトを選択",
"zh-CN": "选择Git项目",
"zh-TW": "選擇 Git 專案",
"ko-KR": "Git 프로젝트 선택",
"fr": "Sélectionner un projet Git",
"es": "Seleccionar un proyecto de Git",
"de": "Ein Git-Projekt auswählen",
"it": "Seleziona un progetto Git",
"pt": "Selecionar um projeto do Git",
"ar": "اختر مشروع Git",
"no": "Velg et Git-prosjekt",
"tr": "Depo seç"
},
"BUTTON$SEND": {

View File

@@ -7,18 +7,20 @@ import {
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser } from "#/types/git";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
llm_api_key: null,
llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
@@ -148,14 +150,14 @@ const openHandsHandlers = [
export const handlers = [
...STRIPE_BILLING_HANDLERS,
...openHandsHandlers,
http.get("/api/github/repositories", () =>
http.get("/api/user/repositories", () =>
HttpResponse.json([
{ id: 1, full_name: "octocat/hello-world" },
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("/api/github/user", () => {
const user: GitHubUser = {
http.get("/api/user/info", () => {
const user: GitUser = {
id: 1,
login: "octocat",
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
@@ -190,12 +192,13 @@ export const handlers = [
}),
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
if (Object.keys(settings.provider_tokens).length > 0)
settings.github_token_is_set = true;
if (Object.keys(settings.provider_tokens_set).length > 0)
settings.provider_tokens_set = { github: false, gitlab: false };
return HttpResponse.json(settings);
}),

View File

@@ -2,12 +2,12 @@ import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setReplayJson } from "#/state/initial-query-slice";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { GitRepositoriesSuggestionBox } from "#/components/features/git/git-repositories-suggestion-box";
import { ReplaySuggestionBox } from "../../components/features/suggestions/replay-suggestion-box";
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
import { CodeNotInGitHubLink } from "#/components/features/github/code-not-in-github-link";
import { CodeNotInGitLink } from "#/components/features/git/code-not-in-github-link";
import { HeroHeading } from "#/components/shared/hero-heading";
import { TaskForm } from "#/components/shared/task-form";
import { convertFileToText } from "#/utils/convert-file-to-text";
@@ -18,7 +18,7 @@ function Home() {
const formRef = React.useRef<HTMLFormElement>(null);
const { data: config } = useConfig();
const { data: user } = useGitHubUser();
const { data: user } = useGitUser();
const gitHubAuthUrl = useGitHubAuthUrl({
appMode: config?.APP_MODE || null,
@@ -37,7 +37,7 @@ function Home() {
</div>
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
<GitHubRepositoriesSuggestionBox
<GitRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
@@ -58,7 +58,7 @@ function Home() {
)}
</div>
<div className="w-full flex justify-start mt-2 ml-2">
<CodeNotInGitHubLink />
<CodeNotInGitLink />
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@ export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { githubTokenIsSet } = useAuth();
const { providersAreSet } = useAuth();
const { data: settings } = useSettings();
const { error, isFetching } = useBalance();
const { migrateUserConsent } = useMigrateUserConsent();
@@ -131,7 +131,7 @@ export default function MainApp() {
{renderWaitlistModal && (
<WaitlistModal
ghTokenIsSet={githubTokenIsSet}
ghTokenIsSet={providersAreSet}
githubAuthUrl={gitHubAuthUrl}
/>
)}

View File

@@ -25,6 +25,8 @@ import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
const REMOTE_RUNTIME_OPTIONS = [
{ key: 1, label: "1x (2 core, 8G)" },
@@ -46,6 +48,7 @@ function AccountSettings() {
} = useAIConfigOptions();
const { mutate: saveSettings } = useSaveSettings();
const { handleLogout } = useAppLogout();
const { providerTokensSet, providersAreSet } = useAuth();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
@@ -62,7 +65,6 @@ function AccountSettings() {
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
PROVIDER_TOKENS: settings.PROVIDER_TOKENS || {},
})
);
}
@@ -71,8 +73,11 @@ function AccountSettings() {
};
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
const isLLMKeySet = settings?.LLM_API_KEY === "**********";
const isGitHubTokenSet =
providerTokensSet.includes(ProviderOptions.github) || false;
const isGitLabTokenSet =
providerTokensSet.includes(ProviderOptions.gitlab) || false;
const isLLMKeySet = settings?.LLM_API_KEY_SET;
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
@@ -115,12 +120,14 @@ function AccountSettings() {
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
formData.get("llm-api-key-input")?.toString() ||
(isLLMKeySet
? undefined // don't update if it's already set
: ""); // reset if it's first time save to avoid 500 error
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
@@ -130,22 +137,21 @@ function AccountSettings() {
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const githubToken = formData.get("github-token-input")?.toString();
const newSettings = {
github_token: githubToken,
provider_tokens: githubToken
? {
github: githubToken,
gitlab: "",
}
: undefined,
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
LLM_API_KEY: finalLlmApiKey,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
@@ -271,10 +277,10 @@ function AccountSettings() {
label="API Key"
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
placeholder={isLLMKeySet ? "<hidden>" : ""}
/>
)}
@@ -367,7 +373,7 @@ function AccountSettings() {
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
GitHub Settings
Git Provider Settings
</h2>
{isSaas && hasAppSlug && (
<Link
@@ -422,17 +428,58 @@ function AccountSettings() {
</b>
.
</p>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label="GitLab Token"
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testId="gitlab-token-help-anchor" className="text-xs">
{" "}
Generate a token on{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
or see the{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
documentation
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!providersAreSet}
>
Disconnect Tokens
</BrandButton>
</>
)}
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!isGitHubTokenSet}
>
Disconnect from GitHub
</BrandButton>
</section>
<section className="flex flex-col gap-6">

View File

@@ -7,11 +7,11 @@ export const DEFAULT_SETTINGS: Settings = {
LLM_BASE_URL: "",
AGENT: "CodeActAgent",
LANGUAGE: "en",
LLM_API_KEY: null,
LLM_API_KEY_SET: false,
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
GITHUB_TOKEN_IS_SET: false,
PROVIDER_TOKENS_SET: { github: false, gitlab: false },
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,

View File

@@ -1,9 +1,12 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
type SliceState = {
files: string[]; // base64 encoded images
initialPrompt: string | null;
selectedRepository: string | null;
selectedRepository: GitRepository | null;
selectedRepositoryProvider: Provider | null;
replayJson: string | null;
};
@@ -11,6 +14,7 @@ const initialState: SliceState = {
files: [],
initialPrompt: null,
selectedRepository: null,
selectedRepositoryProvider: null,
replayJson: null,
};
@@ -33,7 +37,7 @@ export const selectedFilesSlice = createSlice({
clearInitialPrompt(state) {
state.initialPrompt = null;
},
setSelectedRepository(state, action: PayloadAction<string | null>) {
setSelectedRepository(state, action: PayloadAction<GitRepository | null>) {
state.selectedRepository = action.payload;
},
clearSelectedRepository(state) {

View File

@@ -2,14 +2,14 @@ enum ArgConfigType {
LLM_MODEL = "LLM_MODEL",
AGENT = "AGENT",
LANGUAGE = "LANGUAGE",
LLM_API_KEY = "LLM_API_KEY",
LLM_API_KEY_SET = "LLM_API_KEY_SET",
}
const SupportedSettings: string[] = [
ArgConfigType.LLM_MODEL,
ArgConfigType.AGENT,
ArgConfigType.LANGUAGE,
ArgConfigType.LLM_API_KEY,
ArgConfigType.LLM_API_KEY_SET,
];
export { ArgConfigType, SupportedSettings };

View File

@@ -17,7 +17,7 @@ export interface InitConfig {
AGENT: string;
CONFIRMATION_MODE: boolean;
LANGUAGE: string;
LLM_API_KEY: string;
LLM_API_KEY_SET: boolean;
LLM_MODEL: string;
};
token?: string;

View File

@@ -1,10 +1,12 @@
import { Provider } from "#/types/settings";
interface GitHubErrorReponse {
message: string;
documentation_url: string;
status: number;
}
interface GitHubUser {
interface GitUser {
id: number;
login: string;
avatar_url: string;
@@ -13,9 +15,10 @@ interface GitHubUser {
email: string | null;
}
interface GitHubRepository {
interface GitRepository {
id: number;
full_name: string;
git_provider: Provider;
stargazers_count?: number;
link_header?: string;
}

View File

@@ -1,15 +1,20 @@
export type Provider = "github" | "gitlab";
export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
} as const;
export type Provider = keyof typeof ProviderOptions;
export type Settings = {
LLM_MODEL: string;
LLM_BASE_URL: string;
AGENT: string;
LANGUAGE: string;
LLM_API_KEY: string | null;
LLM_API_KEY_SET: boolean;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
GITHUB_TOKEN_IS_SET: boolean;
PROVIDER_TOKENS_SET: Record<Provider, boolean>;
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
@@ -23,19 +28,21 @@ export type ApiSettings = {
agent: string;
language: string;
llm_api_key: string | null;
llm_api_key_set: boolean;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number | null;
github_token_is_set: boolean;
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens: Record<Provider, string>;
provider_tokens_set: Record<Provider, boolean>;
};
export type PostSettings = Settings & {
provider_tokens: Record<Provider, string>;
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
};
export type PostApiSettings = ApiSettings & {

View File

@@ -1,7 +1,7 @@
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Settings): boolean =>
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==

View File

@@ -47,7 +47,9 @@ const extractAdvancedFormData = (formData: FormData) => {
};
};
export const extractSettings = (formData: FormData): Partial<Settings> => {
export const extractSettings = (
formData: FormData,
): Partial<Settings> & { llm_api_key?: string | null } => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
@@ -73,7 +75,7 @@ export const extractSettings = (formData: FormData): Partial<Settings> => {
return {
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY,
LLM_API_KEY_SET: !!LLM_API_KEY,
AGENT,
LANGUAGE,
LLM_BASE_URL,
@@ -81,5 +83,6 @@ export const extractSettings = (formData: FormData): Partial<Settings> => {
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
PROVIDER_TOKENS: providerTokens,
llm_api_key: LLM_API_KEY,
};
};

View File

@@ -65,7 +65,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<AuthProvider initialGithubTokenIsSet>
<AuthProvider initialProviderTokens={[]}>
<QueryClientProvider
client={
new QueryClient({

View File

@@ -0,0 +1,35 @@
---
name: gitlab
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- gitlab
- git
---
You have access to an environment variable, `GITLAB_TOKEN`, which allows you to interact with
the GitLab API.
You can use `curl` with the `GITLAB_TOKEN` to interact with GitLab's API.
ALWAYS use the GitLab API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITLAB_TOKEN}@gitlab.com/username/repo.git`
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitLab API to create a merge request, if you haven't already
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a merge request, send the user a short message with a link to the merge request.
* Prefer "Draft" merge requests when possible
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
```

View File

@@ -227,11 +227,14 @@ class AgentController:
e: Exception,
):
"""React to an exception by setting the agent state to error and sending a status message."""
await self.set_agent_state_to(AgentState.ERROR)
# Store the error reason before setting the agent state
self.state.last_error = f'{type(e).__name__}: {str(e)}'
if self.status_callback is not None:
err_id = ''
if isinstance(e, AuthenticationError):
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
self.state.last_error = err_id
elif isinstance(
e,
(
@@ -241,14 +244,21 @@ class AgentController:
),
):
err_id = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
self.state.last_error = err_id
elif isinstance(e, InternalServerError):
err_id = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
self.state.last_error = err_id
elif isinstance(e, BadRequestError) and 'ExceededBudget' in str(e):
err_id = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
# Set error reason for budget exceeded
self.state.last_error = err_id
elif isinstance(e, RateLimitError):
await self.set_agent_state_to(AgentState.RATE_LIMITED)
return
self.status_callback('error', err_id, type(e).__name__ + ': ' + str(e))
self.status_callback('error', err_id, self.state.last_error)
# Set the agent state to ERROR after storing the reason
await self.set_agent_state_to(AgentState.ERROR)
def step(self):
asyncio.create_task(self._step_with_exception_handling())
@@ -581,8 +591,14 @@ class AgentController:
self.event_stream.add_event(self._pending_action, EventSource.AGENT)
self.state.agent_state = new_state
# Create observation with reason field if it's an error state
reason = ''
if new_state == AgentState.ERROR:
reason = self.state.last_error
self.event_stream.add_event(
AgentStateChangedObservation('', self.state.agent_state),
AgentStateChangedObservation('', self.state.agent_state, reason),
EventSource.ENVIRONMENT,
)

View File

@@ -23,6 +23,7 @@ from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
from openhands.storage import get_file_store
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
def create_runtime(
@@ -117,10 +118,8 @@ def initialize_repository_for_runtime(
repo_directory = None
if selected_repository and provider_tokens:
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = runtime.clone_repo(
provider_tokens,
selected_repository,
None,
repo_directory = call_async_from_sync(
runtime.clone_repo, GENERAL_TIMEOUT, github_token, selected_repository, None
)
# Run setup script if it exists
runtime.maybe_run_setup_script()

View File

@@ -10,6 +10,7 @@ class AgentStateChangedObservation(Observation):
"""This data class represents the result from delegating to another agent"""
agent_state: str
reason: str = ''
observation: str = ObservationType.AGENT_STATE_CHANGED
@property

View File

@@ -9,6 +9,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
SuggestedTask,
TaskType,
@@ -99,29 +100,46 @@ class GitHubService(GitService):
)
async def get_repositories(
self, page: int, per_page: int, sort: str, installation_id: int | None
self, sort: str, installation_id: int | None
) -> list[Repository]:
params = {'page': str(page), 'per_page': str(per_page)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._fetch_data(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._fetch_data(url, params)
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict]= []
page = 1
next_link: str = headers.get('Link', '')
repos = [
while len(all_repos) < MAX_REPOS:
params = {'page': str(page), 'per_page': str(PER_PAGE)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._fetch_data(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._fetch_data(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [
Repository(
id=repo.get('id'),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
link_header=next_link,
git_provider=ProviderType.GITHUB
)
for repo in response
for repo in all_repos
]
return repos
async def get_installation_ids(self) -> list[int]:
url = f'{self.BASE_URL}/user/installations'
@@ -143,6 +161,7 @@ class GitHubService(GitService):
id=repo.get('id'),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB
)
for repo in repos
]
@@ -290,6 +309,14 @@ class GitHubService(GitService):
except Exception:
return []
async def does_repo_exist(self, repository: str) -> bool:
url = f'{self.BASE_URL}/repos/{repository}'
try:
await self._fetch_data(url)
return True
except Exception:
return False
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',

View File

@@ -2,11 +2,13 @@ import os
from typing import Any
import httpx
from urllib.parse import quote_plus
from pydantic import SecretStr
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
UnknownException,
User,
@@ -95,7 +97,7 @@ class GitLabService(GitService):
async def search_repositories(
self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc'
):
) -> list[Repository]:
url = f'{self.BASE_URL}/search'
params = {
'scope': 'projects',
@@ -104,13 +106,82 @@ class GitLabService(GitService):
'order_by': sort,
'sort': order,
}
response, headers = await self._fetch_data(url, params)
return response, headers
response, _ = await self._fetch_data(url, params)
repos = [
Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB
)
for repo in response
]
return repos
async def get_repositories(
self, page: int, per_page: int, sort: str, installation_id: int | None
self, sort: str, installation_id: int | None
) -> list[Repository]:
return []
if installation_id:
return [] # Not implementing installation_token case yet
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
page = 1
url = f'{self.BASE_URL}/projects'
# Map GitHub's sort values to GitLab's order_by values
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name'
}.get(sort, 'last_activity_at')
while len(all_repos) < MAX_REPOS:
params = {
'page': str(page),
'per_page': str(PER_PAGE),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'owned': 1, # Use 1 instead of True
'membership': 1 # Use 1 instead of True
}
response, headers = await self._fetch_data(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [
Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB
)
for repo in all_repos
]
async def does_repo_exist(self, repository: str) -> bool:
encoded_repo = quote_plus(repository)
url = f'{self.BASE_URL}/projects/{encoded_repo}'
try:
await self._fetch_data(url)
return True
except Exception as e:
print(e)
return False
gitlab_service_cls = os.environ.get(

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from enum import Enum
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, overload
@@ -24,16 +23,12 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
User,
)
class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
class ProviderToken(BaseModel):
token: SecretStr | None = Field(default=None)
user_id: str | None = Field(default=None)
@@ -194,21 +189,71 @@ class ProviderHandler:
return await service.get_latest_token()
async def get_repositories(
self, page: int, per_page: int, sort: str, installation_id: int | None
self,
sort: str,
installation_id: int | None,
) -> list[Repository]:
"""Get repositories from all available providers"""
all_repos = []
"""
Get repositories from a selected providers with pagination support
"""
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
repos = await service.get_repositories(
page, per_page, sort, installation_id
)
all_repos.extend(repos)
service_repos = await service.get_repositories(sort, installation_id)
all_repos.extend(service_repos)
except Exception:
continue
return all_repos
async def search_repositories(
self,
query: str,
per_page: int,
sort: str,
order: str,
):
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.search_repositories(
query, per_page, sort, order
)
all_repos.extend(service_repos)
except Exception:
continue
return all_repos
async def get_remote_repository_url(self, repository: str) -> str | None:
if not repository:
return None
provider_domains = {
ProviderType.GITHUB: 'github.com',
ProviderType.GITLAB: 'gitlab.com',
}
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
repo_exists = await service.does_repo_exist(repository)
if repo_exists:
git_token = self.provider_tokens[provider].token
if git_token and provider in provider_domains:
domain = provider_domains[provider]
if provider == ProviderType.GITLAB:
return f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
return f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
except Exception:
continue
return None
async def set_event_stream_secrets(
self,
event_stream: EventStream,

View File

@@ -4,6 +4,11 @@ from typing import Protocol
from pydantic import BaseModel, SecretStr
class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
class TaskType(str, Enum):
MERGE_CONFLICTS = 'MERGE_CONFLICTS'
FAILING_CHECKS = 'FAILING_CHECKS'
@@ -31,8 +36,10 @@ class User(BaseModel):
class Repository(BaseModel):
id: int
full_name: str
git_provider: ProviderType
stargazers_count: int | None = None
link_header: str | None = None
pushed_at: str | None = None # ISO 8601 format date string
class AuthenticationError(ValueError):
@@ -81,10 +88,11 @@ class GitService(Protocol):
async def get_repositories(
self,
page: int,
per_page: int,
sort: str,
installation_id: int | None,
) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def does_repo_exist(self, repository: str) -> bool:
"""Check if a repository exists for the user"""

View File

@@ -1,3 +1,5 @@
from typing import Generator
from litellm import ModelResponse
from openhands.core.config.agent_config import AgentConfig
@@ -125,7 +127,7 @@ class ConversationMemory:
pending_tool_call_action_messages.pop(response_id)
messages += messages_to_add
messages = list(ConversationMemory._filter_unmatched_tool_calls(messages))
return messages
def process_initial_messages(self, with_caching: bool = False) -> list[Message]:
@@ -592,3 +594,58 @@ class ConversationMemory:
):
return True
return False
@staticmethod
def _filter_unmatched_tool_calls(
messages: list[Message],
) -> Generator[Message, None, None]:
"""Filter out tool calls that don't have matching tool responses and vice versa.
This ensures that every tool_call_id in a tool message has a corresponding tool_calls[].id
in an assistant message, and vice versa. The original list is unmodified, when tool_calls is
updated the message is copied.
This does not remove items with id set to None.
"""
tool_call_ids = {
tool_call.id
for message in messages
if message.tool_calls
for tool_call in message.tool_calls
if message.role == 'assistant' and tool_call.id
}
tool_response_ids = {
message.tool_call_id
for message in messages
if message.role == 'tool' and message.tool_call_id
}
for message in messages:
# Remove tool messages with no matching assistant tool call
if message.role == 'tool' and message.tool_call_id:
if message.tool_call_id in tool_call_ids:
yield message
# Remove assistant tool calls with no matching tool response
elif message.role == 'assistant' and message.tool_calls:
all_tool_calls_match = all(
tool_call.id in tool_response_ids
for tool_call in message.tool_calls
)
if all_tool_calls_match:
yield message
else:
matched_tool_calls = [
tool_call
for tool_call in message.tool_calls
if tool_call.id in tool_response_ids
]
if matched_tool_calls:
# Keep an updated message if there are tools calls left
yield message.model_copy(
update={'tool_calls': matched_tool_calls}
)
else:
# Any other case is kept
yield message

View File

@@ -328,7 +328,7 @@ def main() -> None:
runtime_container_image = my_args.runtime_container_image
if runtime_container_image is None:
runtime_container_image = 'ghcr.io/all-hands-ai/runtime:0.30.0-nikolaik'
runtime_container_image = 'ghcr.io/all-hands-ai/runtime:0.31.0-nikolaik'
owner, repo = my_args.selected_repo.split('/')
token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')

View File

@@ -153,6 +153,7 @@ class Runtime(FileEditRuntimeMixin):
)
self.user_id = user_id
self.git_provider_tokens = git_provider_tokens
# TODO: remove once done debugging expired github token
self.prev_token: SecretStr | None = None
@@ -321,28 +322,25 @@ class Runtime(FileEditRuntimeMixin):
return
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
def clone_repo(
async def clone_repo(
self,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
selected_repository: str,
selected_branch: str | None,
) -> str:
if (
ProviderType.GITHUB not in git_provider_tokens
or not git_provider_tokens[ProviderType.GITHUB].token
or not selected_repository
):
raise ValueError(
'github_token and selected_repository must be provided to clone a repository'
)
provider_handler = ProviderHandler(provider_tokens=git_provider_tokens)
remote_repo_url = await provider_handler.get_remote_repository_url(
selected_repository
)
if not remote_repo_url:
raise ValueError('Missing either Git token or valid repository')
if self.status_callback:
self.status_callback(
'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
)
github_token: SecretStr = git_provider_tokens[ProviderType.GITHUB].token
url = f'https://{github_token.get_secret_value()}@github.com/{selected_repository}.git'
dir_name = selected_repository.split('/')[-1]
# Generate a random branch name to avoid conflicts
@@ -352,7 +350,7 @@ class Runtime(FileEditRuntimeMixin):
openhands_workspace_branch = f'openhands-workspace-{random_str}'
# Clone repository command
clone_command = f'git clone {url} {dir_name}'
clone_command = f'git clone {remote_repo_url} {dir_name}'
# Checkout to appropriate branch
checkout_command = (

View File

@@ -2,6 +2,8 @@ from functools import lru_cache
from typing import Callable
from uuid import UUID
import os
import docker
import httpx
import tenacity
@@ -87,6 +89,10 @@ class DockerRuntime(ActionExecutionClient):
self._vscode_port = -1
self._app_ports: list[int] = []
if os.environ.get("DOCKER_HOST_ADDR"):
logger.info(f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url')
self.config.sandbox.local_runtime_url = f'http://{os.environ["DOCKER_HOST_ADDR"]}'
self.docker_client: docker.DockerClient = self._init_docker_client()
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'

View File

@@ -14,6 +14,7 @@ ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
# Install base system dependencies
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
wget curl sudo apt-utils git jq tmux \
{%- if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) -%}
@@ -22,6 +23,10 @@ RUN apt-get update && \
libgl1-mesa-glx \
{% endif -%}
libasound2-plugins libatomic1 && \
# Remove packages with CVEs and no updates yet, if present
(apt-get remove -y libaom3 || true) && \
(apt-get remove -y libjxl0.7 || true) && \
(apt-get remove -y libopenexr-3-1-30 || true) && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

View File

@@ -13,7 +13,7 @@ from openhands import __version__
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.github import app as github_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
@@ -50,5 +50,5 @@ app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(github_api_router)
app.include_router(git_api_router)
app.include_router(trajectory_router)

View File

@@ -5,9 +5,9 @@ from openhands.server.listen_socket import sio
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
GitHubTokenMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
ProviderTokenMiddleware,
RateLimitMiddleware,
)
from openhands.server.static import SPAStaticFiles
@@ -29,6 +29,6 @@ base_app.add_middleware(
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
)
base_app.middleware('http')(AttachConversationMiddleware(base_app))
base_app.middleware('http')(GitHubTokenMiddleware(base_app))
base_app.middleware('http')(ProviderTokenMiddleware(base_app))
app = socketio.ASGIApp(sio, other_asgi_app=base_app)

View File

@@ -185,7 +185,7 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
return response
class GitHubTokenMiddleware(SessionMiddlewareInterface):
class ProviderTokenMiddleware(SessionMiddlewareInterface):
def __init__(self, app):
self.app = app

View File

@@ -17,27 +17,29 @@ from openhands.integrations.service_types import (
)
from openhands.server.auth import get_access_token, get_provider_tokens
app = APIRouter(prefix='/api/github')
app = APIRouter(prefix='/api/user')
from pydantic import BaseModel
@app.get('/repositories', response_model=list[Repository])
async def get_github_repositories(
page: int = 1,
per_page: int = 10,
async def get_user_repositories(
sort: str = 'pushed',
installation_id: int | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
):
if provider_tokens and ProviderType.GITHUB in provider_tokens:
token = provider_tokens[ProviderType.GITHUB]
client = GithubServiceImpl(
user_id=token.user_id, external_auth_token=access_token, token=token.token
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens, external_auth_token=access_token
)
try:
repos: list[Repository] = await client.get_repositories(
page, per_page, sort, installation_id
sort, installation_id
)
return repos
@@ -54,13 +56,13 @@ async def get_github_repositories(
)
return JSONResponse(
content='GitHub token required.',
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/user', response_model=User)
async def get_github_user(
@app.get('/info', response_model=User)
async def get_user(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
):
@@ -86,7 +88,7 @@ async def get_github_user(
)
return JSONResponse(
content='GitHub token required.',
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@@ -125,7 +127,7 @@ async def get_github_installation_ids(
@app.get('/search/repositories', response_model=list[Repository])
async def search_github_repositories(
async def search_repositories(
query: str,
per_page: int = 5,
sort: str = 'stars',
@@ -133,11 +135,10 @@ async def search_github_repositories(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
):
if provider_tokens and ProviderType.GITHUB in provider_tokens:
token = provider_tokens[ProviderType.GITHUB]
client = GithubServiceImpl(
user_id=token.user_id, external_auth_token=access_token, token=token.token
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens, external_auth_token=access_token
)
try:
repos: list[Repository] = await client.search_repositories(

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter, Body, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from openhands.core.config.llm_config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.events.event import EventSource
@@ -34,6 +35,7 @@ from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import generate_conversation_title
app = APIRouter(prefix='/api')
@@ -243,24 +245,6 @@ async def get_conversation(
try:
metadata = await conversation_store.get_metadata(conversation_id)
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
# Check if we need to update the title
if is_running and metadata:
# Check if the title is a default title (contains the conversation ID)
if metadata.title and conversation_id[:5] in metadata.title:
# Generate a new title
new_title = await auto_generate_title(
conversation_id, get_user_id(request)
)
if new_title:
# Update the metadata
metadata.title = new_title
await conversation_store.save_metadata(metadata)
# Refresh metadata after update
metadata = await conversation_store.get_metadata(conversation_id)
conversation_info = await _get_conversation_info(metadata, is_running)
return conversation_info
except FileNotFoundError:
@@ -311,10 +295,6 @@ async def auto_generate_title(conversation_id: str, user_id: str | None) -> str:
break
if first_user_message:
# Try LLM-based title generation first
from openhands.core.config.llm_config import LLMConfig
from openhands.utils.conversation_summary import generate_conversation_title
# Get LLM config from user settings
try:
settings_store = await SettingsStoreImpl.get_instance(config, user_id)

View File

@@ -25,13 +25,27 @@ async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
content={'error': 'Settings not found'},
)
github_token_is_set = bool(user_id) or bool(get_provider_tokens(request))
provider_tokens_set = {}
if bool(user_id):
provider_tokens_set[ProviderType.GITHUB.value] = True
provider_tokens = get_provider_tokens(request)
if provider_tokens:
all_provider_types = [provider.value for provider in ProviderType]
provider_tokens_types = [provider.value for provider in provider_tokens]
for provider_type in all_provider_types:
if provider_type in provider_tokens_types:
provider_tokens_set[provider_type] = True
else:
provider_tokens_set[provider_type] = False
settings_with_token_data = GETSettingsModel(
**settings.model_dump(exclude='secrets_store'),
github_token_is_set=github_token_is_set,
llm_api_key_set=settings.llm_api_key is not None,
provider_tokens_set=provider_tokens_set,
)
settings_with_token_data.llm_api_key = settings.llm_api_key
settings_with_token_data.llm_api_key = None
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')

View File

@@ -323,12 +323,9 @@ class AgentSession:
return False
if selected_repository and git_provider_tokens:
await call_sync_from_async(
self.runtime.clone_repo,
git_provider_tokens,
selected_repository,
selected_branch,
)
await self.runtime.clone_repo(git_provider_tokens,
selected_repository,
selected_branch)
await call_sync_from_async(self.runtime.maybe_run_setup_script)
self.logger.debug(

View File

@@ -120,4 +120,5 @@ class GETSettingsModel(Settings):
Settings with additional token data for the frontend
"""
github_token_is_set: bool | None = None
provider_tokens_set: dict[str, bool] | None = None
llm_api_key_set: bool

58
poetry.lock generated
View File

@@ -496,18 +496,18 @@ files = [
[[package]]
name = "boto3"
version = "1.37.23"
version = "1.37.24"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "boto3-1.37.23-py3-none-any.whl", hash = "sha256:fc462b9fd738bd8a1c121d94d237c6b6a05a2c1cc709d16f5223acb752f7310b"},
{file = "boto3-1.37.23.tar.gz", hash = "sha256:82f4599a34f5eb66e916b9ac8547394f6e5899c19580e74b60237db04cf66d1e"},
{file = "boto3-1.37.24-py3-none-any.whl", hash = "sha256:2f2b8f82a5d7f89283973bf2cab771b90c09348799e78b2a25c60cd22c443514"},
{file = "boto3-1.37.24.tar.gz", hash = "sha256:1d3c6fc63a9efba0af8b531ec6b7f7c6b0ef197bf3dcd875f03c9097ac68b58f"},
]
[package.dependencies]
botocore = ">=1.37.23,<1.38.0"
botocore = ">=1.37.24,<1.38.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.11.0,<0.12.0"
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "boto3-stubs"
version = "1.37.23"
description = "Type annotations for boto3 1.37.23 generated with mypy-boto3-builder 8.10.1"
version = "1.37.24"
description = "Type annotations for boto3 1.37.24 generated with mypy-boto3-builder 8.10.1"
optional = false
python-versions = ">=3.8"
groups = ["evaluation"]
files = [
{file = "boto3_stubs-1.37.23-py3-none-any.whl", hash = "sha256:a00884a3df819bdc6b040c857e57a87b4f33df963ee88f8f406b13bf2cd983ca"},
{file = "boto3_stubs-1.37.23.tar.gz", hash = "sha256:011f06dadcd5ef3c627ec9808b9afa4e1837b0f009d82b8209f12a84ffbb3867"},
{file = "boto3_stubs-1.37.24-py3-none-any.whl", hash = "sha256:0c085621dcfb861be1b3066aaed294eca37a2f99d9e737b41dc2de3a26498c27"},
{file = "boto3_stubs-1.37.24.tar.gz", hash = "sha256:42f7c1b3da40eb074ffc830b26417c9af86546a609fd8563d7af4deade3b5194"},
]
[package.dependencies]
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.37.0,<1.38.0)"]
billing = ["mypy-boto3-billing (>=1.37.0,<1.38.0)"]
billingconductor = ["mypy-boto3-billingconductor (>=1.37.0,<1.38.0)"]
boto3 = ["boto3 (==1.37.23)"]
boto3 = ["boto3 (==1.37.24)"]
braket = ["mypy-boto3-braket (>=1.37.0,<1.38.0)"]
budgets = ["mypy-boto3-budgets (>=1.37.0,<1.38.0)"]
ce = ["mypy-boto3-ce (>=1.37.0,<1.38.0)"]
@@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.37.0,<1.38.0)"]
[[package]]
name = "botocore"
version = "1.37.23"
version = "1.37.24"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "botocore-1.37.23-py3-none-any.whl", hash = "sha256:ffbe1f5958adb1c50d72d3ad1018cb265fe349248c08782d334601c0814f0e38"},
{file = "botocore-1.37.23.tar.gz", hash = "sha256:3a249c950cef9ee9ed7b2278500ad83a4ad6456bc433a43abd1864d1b61b2acb"},
{file = "botocore-1.37.24-py3-none-any.whl", hash = "sha256:f1a55332cca85a6556af8941cccdaf5d2d00336647d9e89f31174f2361ffb4f2"},
{file = "botocore-1.37.24.tar.gz", hash = "sha256:a0bcc3c376a371f2c11afcbcc9917010c1c0a701d0e45d1ea3ec3bddeb06a8ff"},
]
[package.dependencies]
@@ -2744,14 +2744,14 @@ tool = ["click (>=6.0.0)"]
[[package]]
name = "google-cloud-aiplatform"
version = "1.86.0"
version = "1.87.0"
description = "Vertex AI API client library"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "google_cloud_aiplatform-1.86.0-py2.py3-none-any.whl", hash = "sha256:fcd155a0e77fdc12a5c477af92fa0b7e8ea1b1d1fcece35ad07f160008dedc7e"},
{file = "google_cloud_aiplatform-1.86.0.tar.gz", hash = "sha256:45fff84c75c6f66105efa1c6caf0ea87fddc85298c834ee38f4163cf793510c4"},
{file = "google_cloud_aiplatform-1.87.0-py2.py3-none-any.whl", hash = "sha256:23421cb288550e1b35c5a81f471618e0c34c32dfc73db895498ac01c9a5fa46c"},
{file = "google_cloud_aiplatform-1.87.0.tar.gz", hash = "sha256:3628c573d677d72c7ad62e9c898ff7073c915241e86fd563ff2ee581d281e494"},
]
[package.dependencies]
@@ -4393,14 +4393,14 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.65.0"
version = "1.65.1"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
groups = ["main"]
files = [
{file = "litellm-1.65.0-py3-none-any.whl", hash = "sha256:bbc211f3d03e1830ed7f4304b40f70fa1fa4a2f9109d006ede5f78e83a189aba"},
{file = "litellm-1.65.0.tar.gz", hash = "sha256:147a74d18601ccaaff3ca125eba914ab6e5b5854aff480dce5a52be5b9d52ff8"},
{file = "litellm-1.65.1-py3-none-any.whl", hash = "sha256:56478866373c4af13d86e71ab44fd337305dee39a82d742bc496bc56a2d732a1"},
{file = "litellm-1.65.1.tar.gz", hash = "sha256:cbc8d7dfa5f7f47e6842796ca0c39682eb874718faab17fa991ab6c2f55e844c"},
]
[package.dependencies]
@@ -4410,7 +4410,7 @@ httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.22.0,<5.0.0"
openai = ">=1.66.1"
openai = ">=1.68.2"
pydantic = ">=2.0.0,<3.0.0"
python-dotenv = ">=0.2.0"
tiktoken = ">=0.7.0"
@@ -4418,7 +4418,7 @@ tokenizers = "*"
[package.extras]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-proxy-extras (==0.1.1)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"]
[[package]]
name = "lxml"
@@ -4836,14 +4836,14 @@ files = [
[[package]]
name = "modal"
version = "0.73.136"
version = "0.73.138"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
{file = "modal-0.73.136-py3-none-any.whl", hash = "sha256:1f812712ea616cce949c06c5a4b45497d1157879775986de54db9ed2023b79e9"},
{file = "modal-0.73.136.tar.gz", hash = "sha256:e8a6d3961c11e6440b2ab9a7f344fb1beb9aae8b8511df871ce3b2399f194af0"},
{file = "modal-0.73.138-py3-none-any.whl", hash = "sha256:da60510d789509e1a5c9c4a2458e0295dfbee768eae71f0bba739b99215d2a08"},
{file = "modal-0.73.138.tar.gz", hash = "sha256:943daebf7dea70d16b978c633ff3cfc1d453151043f64c7a9012e2526b6be2d2"},
]
[package.dependencies]
@@ -5392,14 +5392,14 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "openai"
version = "1.69.0"
version = "1.70.0"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation", "test"]
files = [
{file = "openai-1.69.0-py3-none-any.whl", hash = "sha256:73c4b2ddfd050060f8d93c70367189bd891e70a5adb6d69c04c3571f4fea5627"},
{file = "openai-1.69.0.tar.gz", hash = "sha256:7b8a10a8ff77e1ae827e5e4c8480410af2070fb68bc973d6c994cf8218f1f98d"},
{file = "openai-1.70.0-py3-none-any.whl", hash = "sha256:f6438d053fd8b2e05fd6bef70871e832d9bbdf55e119d0ac5b92726f1ae6f614"},
{file = "openai-1.70.0.tar.gz", hash = "sha256:e52a8d54c3efeb08cf58539b5b21a5abef25368b5432965e4de88cdf4e091b2b"},
]
[package.dependencies]
@@ -6674,14 +6674,14 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "6.0.0"
version = "6.1.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
{file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
{file = "pytest_cov-6.1.0-py3-none-any.whl", hash = "sha256:cd7e1d54981d5185ef2b8d64b50172ce97e6f357e6df5cb103e828c7f993e201"},
{file = "pytest_cov-6.1.0.tar.gz", hash = "sha256:ec55e828c66755e5b74a21bd7cc03c303a9f928389c0563e50ba454a6dbe71db"},
]
[package.dependencies]

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.30.1"
version = "0.31.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -97,7 +97,6 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -126,7 +125,6 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"

View File

@@ -17,6 +17,7 @@ from openhands.events.action import ChangeAgentStateAction, CmdRunAction, Messag
from openhands.events.action.agent import RecallAction
from openhands.events.event import RecallType
from openhands.events.observation import (
AgentStateChangedObservation,
ErrorObservation,
)
from openhands.events.observation.agent import RecallObservation
@@ -217,9 +218,17 @@ async def test_run_controller_with_fatal_error(test_event_stream, mock_memory):
print(f'state: {state}')
events = list(test_event_stream.get_events())
print(f'event_stream: {events}')
error_observations = test_event_stream.get_matching_events(
reverse=True, limit=1, event_types=(AgentStateChangedObservation)
)
assert len(error_observations) == 1
error_observation = error_observations[0]
assert state.iteration == 3
assert state.agent_state == AgentState.ERROR
assert state.last_error == 'AgentStuckInLoopError: Agent got stuck in a loop'
assert (
error_observation.reason == 'AgentStuckInLoopError: Agent got stuck in a loop'
)
assert len(events) == 11
@@ -622,6 +631,17 @@ async def test_run_controller_max_iterations_has_metrics(
state.last_error
== 'RuntimeError: Agent reached maximum iteration in headless mode. Current iteration: 3, max iteration: 3'
)
error_observations = test_event_stream.get_matching_events(
reverse=True, limit=1, event_types=(AgentStateChangedObservation)
)
assert len(error_observations) == 1
error_observation = error_observations[0]
assert (
error_observation.reason
== 'RuntimeError: Agent reached maximum iteration in headless mode. Current iteration: 3, max iteration: 3'
)
assert (
state.metrics.accumulated_cost == 10.0 * 3
), f'Expected accumulated cost to be 30.0, but got {state.metrics.accumulated_cost}'
@@ -896,6 +916,16 @@ async def test_run_controller_with_context_window_exceeded_without_truncation(
== 'LLMContextWindowExceedError: Conversation history longer than LLM context window limit. Consider turning on enable_history_truncation config to avoid this error'
)
error_observations = test_event_stream.get_matching_events(
reverse=True, limit=1, event_types=(AgentStateChangedObservation)
)
assert len(error_observations) == 1
error_observation = error_observations[0]
assert (
error_observation.reason
== 'LLMContextWindowExceedError: Conversation history longer than LLM context window limit. Consider turning on enable_history_truncation config to avoid this error'
)
# Check that the context window exceeded error was raised during the run
assert step_state.has_errored

View File

@@ -3,6 +3,7 @@ import shutil
from unittest.mock import MagicMock, Mock
import pytest
from litellm import ChatCompletionMessageToolCall
from openhands.controller.state.state import State
from openhands.core.config.agent_config import AgentConfig
@@ -1050,3 +1051,150 @@ def test_has_agent_in_earlier_events(conversation_memory):
conversation_memory._has_agent_in_earlier_events('non_existent', 3, events)
is False
)
class TestFilterUnmatchedToolCalls:
@pytest.fixture
def processor(self):
return ConversationMemory()
def test_empty_is_unchanged(self):
assert list(ConversationMemory._filter_unmatched_tool_calls([])) == []
def test_no_tool_calls_is_unchanged(self):
messages = [
Message(role='user', content=[TextContent(text='Hello')]),
Message(role='assistant', content=[TextContent(text='Hi there')]),
Message(role='user', content=[TextContent(text='How are you?')]),
]
assert (
list(ConversationMemory._filter_unmatched_tool_calls(messages)) == messages
)
def test_matched_tool_calls_are_unchanged(self):
messages = [
Message(role='user', content=[TextContent(text="What's the weather?")]),
Message(
role='assistant',
content=[],
tool_calls=[
ChatCompletionMessageToolCall(
id='call_1',
type='function',
function={'name': 'get_weather', 'arguments': ''},
)
],
),
Message(
role='tool',
tool_call_id='call_1',
content=[TextContent(text='Sunny, 75°F')],
),
Message(role='assistant', content=[TextContent(text="It's sunny today.")]),
]
# All tool calls have matching responses, should remain unchanged
assert (
list(ConversationMemory._filter_unmatched_tool_calls(messages)) == messages
)
def test_tool_call_without_response_is_removed(self):
messages = [
Message(role='user', content=[TextContent(text='Query')]),
Message(
role='tool',
tool_call_id='missing_call',
content=[TextContent(text='Response')],
),
Message(role='assistant', content=[TextContent(text='Answer')]),
]
expected_after_filter = [
Message(role='user', content=[TextContent(text='Query')]),
Message(role='assistant', content=[TextContent(text='Answer')]),
]
result = list(ConversationMemory._filter_unmatched_tool_calls(messages))
assert result == expected_after_filter
def test_tool_response_without_call_is_removed(self):
messages = [
Message(role='user', content=[TextContent(text='Query')]),
Message(
role='assistant',
content=[],
tool_calls=[
ChatCompletionMessageToolCall(
id='unmatched_call',
type='function',
function={'name': 'some_function', 'arguments': ''},
)
],
),
Message(role='assistant', content=[TextContent(text='Answer')]),
]
expected_after_filter = [
Message(role='user', content=[TextContent(text='Query')]),
Message(role='assistant', content=[TextContent(text='Answer')]),
]
result = list(ConversationMemory._filter_unmatched_tool_calls(messages))
assert result == expected_after_filter
def test_partial_matched_tool_calls_retains_matched(self):
"""When there are both matched and unmatched tools calls in a message, retain the message and only matched calls"""
messages = [
Message(role='user', content=[TextContent(text='Get data')]),
Message(
role='assistant',
content=[],
tool_calls=[
ChatCompletionMessageToolCall(
id='matched_call',
type='function',
function={'name': 'function1', 'arguments': ''},
),
ChatCompletionMessageToolCall(
id='unmatched_call',
type='function',
function={'name': 'function2', 'arguments': ''},
),
],
),
Message(
role='tool',
tool_call_id='matched_call',
content=[TextContent(text='Data')],
),
Message(role='assistant', content=[TextContent(text='Result')]),
]
expected = [
Message(role='user', content=[TextContent(text='Get data')]),
# This message should be modified to only include the matched tool call
Message(
role='assistant',
content=[],
tool_calls=[
ChatCompletionMessageToolCall(
id='matched_call',
type='function',
function={'name': 'function1', 'arguments': ''},
)
],
),
Message(
role='tool',
tool_call_id='matched_call',
content=[TextContent(text='Data')],
),
Message(role='assistant', content=[TextContent(text='Result')]),
]
result = list(ConversationMemory._filter_unmatched_tool_calls(messages))
# Verify result structure
assert len(result) == len(expected)
for i, msg in enumerate(result):
assert msg == expected[i]