Compare commits

..

26 Commits

Author SHA1 Message Date
Robert Brennan 8ac8a35811 Merge branch 'main' into rb/github-patch 2024-11-11 18:35:00 -05:00
Robert Brennan 9d3c6d87fb Merge branch 'rb/fix-remote' into rb/github-patch 2024-11-11 18:30:24 -05:00
Robert Brennan 7df7f43e3c Revert "Add rate limiting to server endpoints" (#4910) 2024-11-11 23:26:49 +00:00
Robert Brennan 4c935a84e7 another attempt 2024-11-11 18:10:40 -05:00
tofarr 2ad0831560 Merge branch 'main' into revert-4867-feature/add-rate-limiting 2024-11-11 15:53:20 -07:00
Engel Nyst a45aba512a Tweak log levels (#4729) 2024-11-11 22:51:56 +00:00
Robert Brennan d865f1e4a7 Revert "Add rate limiting to server endpoints (#4867)"
This reverts commit 79492b6551.
2024-11-11 17:41:15 -05:00
tofarr a1a9d2f175 Refactor websocket (#4879)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-11 22:36:07 +00:00
Robert Brennan 79492b6551 Add rate limiting to server endpoints (#4867)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-11 16:54:22 -05:00
sp.wack 80fdb9a2f4 feat(posthog): Emit user activated event (#4886) 2024-11-11 23:31:41 +02:00
Robert Brennan a38c45cf75 fix remote runtimes 2024-11-11 15:44:42 -05:00
Nafis Reza 975e75531d Move assets/icons to dedicated folder (#4850) 2024-11-11 20:17:04 +00:00
Robert Brennan 1b5f5bcdad fixes for upcoming changes to remote API (#4834) 2024-11-11 14:51:14 -05:00
Rohit Malhotra 8c00d96024 Support displaying images/videos/pdfs in the workspace (#4898) 2024-11-11 20:22:17 +02:00
Robert Brennan bf8ccc8fc3 fix infinite loop (#4873)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-11 10:59:43 +00:00
OpenHands 037d770f66 Fix issue #4884: (chore) add missing FE translations (#4885)
Co-authored-by: tobitege <10787084+tobitege@users.noreply.github.com>
2024-11-11 10:09:46 +00:00
sp.wack dd50246672 test(frontend): Pass failing tests (#4887) 2024-11-11 09:49:56 +00:00
Graham Neubig 090771674c Update llms.md w/ more recent results (#4874) 2024-11-10 03:12:09 +00:00
Xingyao Wang d8ab0208ba fix: remove duplicate claude-3-5-sonnet-20241022 model from VERIFIED_MODELS (#4871)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-09 21:41:56 +00:00
Xingyao Wang a07e8272da fix: improve remote runtime reliability on large-scale evaluation (#4869) 2024-11-09 20:17:10 +00:00
Robert Brennan be82832eb1 Use keyword matching for CodeAct microagents (#4568)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2024-11-09 11:25:02 -05:00
ross 67c8915d51 feat(runtime): Add prototype Runloop runtime impl (#4598)
Co-authored-by: Robert Brennan <contact@rbren.io>
2024-11-08 23:40:31 -05:00
Daniel Cruz 40b3ccb17c Adds missing spanish translations (#4858) 2024-11-09 05:14:55 +01:00
Robert Brennan 35c68863dc Don't persist cache on reload (#4854) 2024-11-08 22:31:24 +00:00
mamoodi 8bfee87bcf Release 0.13.0 (#4849) 2024-11-08 22:24:56 +00:00
Robert Brennan e1383afbc3 Add signed cookie-based GitHub authentication caching (#4853)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-08 22:19:34 +00:00
103 changed files with 3053 additions and 2388 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image ### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps: To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image. 1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.12-nikolaik 2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
## Develop inside Docker container ## Develop inside Docker container
+4 -3
View File
@@ -38,15 +38,16 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information. system requirements and more information.
```bash ```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker run -it --pull=always \ docker run -it --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \ -p 3000:3000 \
-e LOG_ALL_EVENTS=true \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app \ --name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.12 docker.all-hands.dev/all-hands-ai/openhands:0.13
``` ```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)! You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest image: openhands:latest
container_name: openhands-app-${DATE:-} container_name: openhands-app-${DATE:-}
environment: environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik} - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports: ports:
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"} - BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal - SANDBOX_API_HOSTNAME=host.docker.internal
# #
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik} - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports: ports:
+2 -2
View File
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash ```bash
docker run -it \ docker run -it \
--pull=always \ --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \ -e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \ -e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \ --name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \ docker.all-hands.dev/all-hands-ai/openhands:0.13 \
python -m openhands.core.cli python -m openhands.core.cli
``` ```
+3 -2
View File
@@ -44,15 +44,16 @@ LLM_API_KEY="sk_test_12345"
```bash ```bash
docker run -it \ docker run -it \
--pull=always \ --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \ -e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \ -e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \ -e LLM_MODEL=$LLM_MODEL \
-e LOG_ALL_EVENTS=true \
-v $WORKSPACE_BASE:/opt/workspace_base \ -v $WORKSPACE_BASE:/opt/workspace_base \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \ --name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \ docker.all-hands.dev/all-hands-ai/openhands:0.13 \
python -m openhands.core.main -t "write a bash script that prints hi" python -m openhands.core.main -t "write a bash script that prints hi"
``` ```
+4 -3
View File
@@ -11,15 +11,16 @@
The easiest way to run OpenHands is in Docker. The easiest way to run OpenHands is in Docker.
```bash ```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker run -it --rm --pull=always \ docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \ -p 3000:3000 \
-e LOG_ALL_EVENTS=true \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app \ --name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.12 docker.all-hands.dev/all-hands-ai/openhands:0.13
``` ```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action). You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
+2 -2
View File
@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
## Model Recommendations ## Model Recommendations
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed). Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and [this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings: When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate with the default agent in OpenHands. - Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task. - GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models. - Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
+4 -2
View File
@@ -35,7 +35,8 @@ def codeact_user_response_eda(state: State) -> str:
# retrieve the latest model message from history # retrieve the latest model message from history
if state.history: if state.history:
model_guess = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
model_guess = last_agent_message.content if last_agent_message else ''
assert game is not None, 'Game is not initialized.' assert game is not None, 'Game is not initialized.'
msg = game.generate_user_response(model_guess) msg = game.generate_user_response(model_guess)
@@ -140,7 +141,8 @@ def process_instance(
if state is None: if state is None:
raise ValueError('State should not be None.') raise ValueError('State should not be None.')
final_message = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
final_message = last_agent_message.content if last_agent_message else ''
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}') logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
test_result = game.reward() test_result = game.reward()
+2 -1
View File
@@ -102,7 +102,8 @@ def process_instance(
raise ValueError('State should not be None.') raise ValueError('State should not be None.')
# retrieve the last message from the agent # retrieve the last message from the agent
model_answer_raw = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
# attempt to parse model_answer # attempt to parse model_answer
ast_eval_fn = instance['ast_eval'] ast_eval_fn = instance['ast_eval']
+1
View File
@@ -83,6 +83,7 @@ def get_config(instance: pd.Series) -> AppConfig:
timeout=1800, timeout=1800,
api_key=os.environ.get('ALLHANDS_API_KEY', None), api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'), remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
), ),
# do not mount workspace # do not mount workspace
workspace_base=None, workspace_base=None,
+1
View File
@@ -146,6 +146,7 @@ def get_config(
api_key=os.environ.get('ALLHANDS_API_KEY', None), api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'), remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_remote_runtime_alive=False, keep_remote_runtime_alive=False,
remote_runtime_init_timeout=1800,
), ),
# do not mount workspace # do not mount workspace
workspace_base=None, workspace_base=None,
+2 -1
View File
@@ -127,7 +127,8 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
raise ValueError('State should not be None.') raise ValueError('State should not be None.')
# retrieve the last message from the agent # retrieve the last message from the agent
model_answer_raw = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
# attempt to parse model_answer # attempt to parse model_answer
correct = eval_answer(str(model_answer_raw), str(answer)) correct = eval_answer(str(model_answer_raw), str(answer))
@@ -16,14 +16,14 @@ describe("Empty state", () => {
send: vi.fn(), send: vi.fn(),
})); }));
const { useSocket: useSocketMock } = vi.hoisted(() => ({ const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })), useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
})); }));
beforeAll(() => { beforeAll(() => {
vi.mock("#/context/socket", async (importActual) => ({ vi.mock("#/context/socket", async (importActual) => ({
...(await importActual<typeof import("#/context/socket")>()), ...(await importActual<typeof import("#/context/ws-client-provider")>()),
useSocket: useSocketMock, useWsClient: useWsClientMock,
})); }));
}); });
@@ -77,7 +77,7 @@ describe("Empty state", () => {
"should load the a user message to the input when selecting", "should load the a user message to the input when selecting",
async () => { async () => {
// this is to test that the message is in the UI before the socket is called // this is to test that the message is in the UI before the socket is called
useSocketMock.mockImplementation(() => ({ useWsClientMock.mockImplementation(() => ({
send: sendMock, send: sendMock,
runtimeActive: false, // mock an inactive runtime setup runtimeActive: false, // mock an inactive runtime setup
})); }));
@@ -106,7 +106,7 @@ describe("Empty state", () => {
it.fails( it.fails(
"should send the message to the socket only if the runtime is active", "should send the message to the socket only if the runtime is active",
async () => { async () => {
useSocketMock.mockImplementation(() => ({ useWsClientMock.mockImplementation(() => ({
send: sendMock, send: sendMock,
runtimeActive: false, // mock an inactive runtime setup runtimeActive: false, // mock an inactive runtime setup
})); }));
@@ -123,7 +123,7 @@ describe("Empty state", () => {
await user.click(displayedSuggestions[0]); await user.click(displayedSuggestions[0]);
expect(sendMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
useSocketMock.mockImplementation(() => ({ useWsClientMock.mockImplementation(() => ({
send: sendMock, send: sendMock,
runtimeActive: true, // mock an active runtime setup runtimeActive: true, // mock an active runtime setup
})); }));
+16 -4
View File
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { afterEach } from "node:test"; import { afterEach } from "node:test";
import { useTerminal } from "#/hooks/useTerminal"; import { useTerminal } from "#/hooks/useTerminal";
import { SocketProvider } from "#/context/socket";
import { Command } from "#/state/commandSlice"; import { Command } from "#/state/commandSlice";
import { WsClientProvider } from "#/context/ws-client-provider";
import { ReactNode } from "react";
interface TestTerminalComponentProps { interface TestTerminalComponentProps {
commands: Command[]; commands: Command[];
@@ -18,6 +19,17 @@ function TestTerminalComponent({
return <div ref={ref} />; return <div ref={ref} />;
} }
interface WrapperProps {
children: ReactNode;
}
function Wrapper({children}: WrapperProps) {
return (
<WsClientProvider enabled={true} token="NO_JWT" ghToken="NO_GITHUB" settings={null}>{children}</WsClientProvider>
)
}
describe("useTerminal", () => { describe("useTerminal", () => {
const mockTerminal = vi.hoisted(() => ({ const mockTerminal = vi.hoisted(() => ({
loadAddon: vi.fn(), loadAddon: vi.fn(),
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
it("should render", () => { it("should render", () => {
render(<TestTerminalComponent commands={[]} secrets={[]} />, { render(<TestTerminalComponent commands={[]} secrets={[]} />, {
wrapper: SocketProvider, wrapper: Wrapper,
}); });
}); });
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
]; ];
render(<TestTerminalComponent commands={commands} secrets={[]} />, { render(<TestTerminalComponent commands={commands} secrets={[]} />, {
wrapper: SocketProvider, wrapper: Wrapper,
}); });
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello"); expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
secrets={[secret, anotherSecret]} secrets={[secret, anotherSecret]}
/>, />,
{ {
wrapper: SocketProvider, wrapper: Wrapper,
}, },
); );
+6 -27
View File
@@ -8,7 +8,6 @@ describe("Cache", () => {
const testTTL = 1000; // 1 second const testTTL = 1000; // 1 second
beforeEach(() => { beforeEach(() => {
localStorage.clear();
vi.useFakeTimers(); vi.useFakeTimers();
}); });
@@ -16,17 +15,7 @@ describe("Cache", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("sets data in localStorage with expiration", () => { it("gets data from memory if not expired", () => {
cache.set(testKey, testData, testTTL);
const cachedEntry = JSON.parse(
localStorage.getItem(`app_cache_${testKey}`) || "",
);
expect(cachedEntry.data).toEqual(testData);
expect(cachedEntry.expiration).toBeGreaterThan(Date.now());
});
it("gets data from localStorage if not expired", () => {
cache.set(testKey, testData, testTTL); cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData); expect(cache.get(testKey)).toEqual(testData);
@@ -39,7 +28,6 @@ describe("Cache", () => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1); vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(cache.get(testKey)).toBeNull(); expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
}); });
it("returns null if cached data is expired", () => { it("returns null if cached data is expired", () => {
@@ -47,28 +35,19 @@ describe("Cache", () => {
vi.advanceTimersByTime(testTTL + 1); vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull(); expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
}); });
it("deletes data from localStorage", () => { it("deletes data from memory", () => {
cache.set(testKey, testData, testTTL); cache.set(testKey, testData, testTTL);
cache.delete(testKey); cache.delete(testKey);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
}); });
it("clears all data with the app prefix from localStorage", () => { it("clears all data with the app prefix from memory", () => {
cache.set(testKey, testData, testTTL); cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL); cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll(); cache.clearAll();
expect(cache.get(testKey)).toBeNull();
expect(localStorage.length).toBe(0); expect(cache.get("anotherKey")).toBeNull();
});
it("does not retrieve non-prefixed data from localStorage when clearing", () => {
localStorage.setItem("nonPrefixedKey", "should remain");
cache.set(testKey, testData, testTTL);
cache.clearAll();
expect(localStorage.getItem("nonPrefixedKey")).toBe("should remain");
}); });
}); });
@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
separator: "/", separator: "/",
}); });
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({ expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
provider: "anthropic", provider: "anthropic",
model: "claude-3-5-sonnet-20241022", model: "claude-3-5-sonnet-20240620",
separator: "/", separator: "/",
}); });
@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
"gpt-4o", "gpt-4o",
"together-ai-21.1b-41b", "together-ai-21.1b-41b",
"gpt-4o-mini", "gpt-4o-mini",
"claude-3-5-sonnet-20241022", "anthropic/claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307", "claude-3-haiku-20240307",
"claude-2", "claude-2",
"claude-2.1", "claude-2.1",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "openhands-frontend", "name": "openhands-frontend",
"version": "0.12.3", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openhands-frontend", "name": "openhands-frontend",
"version": "0.12.3", "version": "0.13.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8", "@nextui-org/react": "^2.4.8",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "openhands-frontend", "name": "openhands-frontend",
"version": "0.12.3", "version": "0.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -120,4 +120,4 @@
"public" "public"
] ]
} }
} }
+2 -2
View File
@@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play";
import { generateAgentStateChangeEvent } from "#/services/agentStateService"; import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { RootState } from "#/store"; import { RootState } from "#/store";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
import { useSocket } from "#/context/socket"; import { useWsClient } from "#/context/ws-client-provider";
const IgnoreTaskStateMap: Record<string, AgentState[]> = { const IgnoreTaskStateMap: Record<string, AgentState[]> = {
[AgentState.PAUSED]: [ [AgentState.PAUSED]: [
@@ -72,7 +72,7 @@ function ActionButton({
} }
function AgentControlBar() { function AgentControlBar() {
const { send } = useSocket(); const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent); const { curAgentState } = useSelector((state: RootState) => state.agent);
const handleAction = (action: AgentState) => { const handleAction = (action: AgentState) => {
@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react"; import Clip from "#/icons/clip.svg?react";
export function AttachImageLabel() { export function AttachImageLabel() {
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import TextareaAutosize from "react-textarea-autosize"; import TextareaAutosize from "react-textarea-autosize";
import ArrowSendIcon from "#/assets/arrow-send.svg?react"; import ArrowSendIcon from "#/icons/arrow-send.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
interface ChatInputProps { interface ChatInputProps {
+3 -3
View File
@@ -1,7 +1,6 @@
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import React from "react"; import React from "react";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { useSocket } from "#/context/socket";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message"; import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions"; import { FeedbackActions } from "./feedback-actions";
@@ -21,14 +20,15 @@ import { ContinueButton } from "./continue-button";
import { ScrollToBottomButton } from "./scroll-to-bottom-button"; import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { Suggestions } from "./suggestions"; import { Suggestions } from "./suggestions";
import { SUGGESTIONS } from "#/utils/suggestions"; import { SUGGESTIONS } from "#/utils/suggestions";
import BuildIt from "#/assets/build-it.svg?react"; import BuildIt from "#/icons/build-it.svg?react";
import { useWsClient } from "#/context/ws-client-provider";
const isErrorMessage = ( const isErrorMessage = (
message: Message | ErrorMessage, message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message; ): message is ErrorMessage => "error" in message;
export function ChatInterface() { export function ChatInterface() {
const { send } = useSocket(); const { send } = useWsClient();
const dispatch = useDispatch(); const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } = const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
@@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
import { generateAgentStateChangeEvent } from "#/services/agentStateService"; import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { useSocket } from "#/context/socket"; import { useWsClient } from "#/context/ws-client-provider";
interface ActionTooltipProps { interface ActionTooltipProps {
type: "confirm" | "reject"; type: "confirm" | "reject";
@@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) {
function ConfirmationButtons() { function ConfirmationButtons() {
const { t } = useTranslation(); const { t } = useTranslation();
const { send } = useSocket(); const { send } = useWsClient();
const handleStateChange = (state: AgentState) => { const handleStateChange = (state: AgentState) => {
const event = generateAgentStateChangeEvent(state); const event = generateAgentStateChangeEvent(state);
+188
View File
@@ -0,0 +1,188 @@
import React from "react";
import {
useFetcher,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { ErrorObservation } from "#/types/core/observations";
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
import { handleAssistantMessage } from "#/services/actions";
import {
getCloneRepoCommand,
getGitHubTokenCommand,
} from "#/services/terminalService";
import {
clearFiles,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chatService";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { isGitHubErrorReponse } from "#/api/github";
import OpenHands from "#/api/open-hands";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
import { getSettings } from "#/services/settings";
interface ServerError {
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export function EventHandler({ children }: React.PropsWithChildren) {
const { events, status, send } = useWsClient();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const fetcher = useFetcher();
const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
const initialQueryRef = React.useRef<string | null>(
store.getState().initalQuery.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const userId = React.useMemo(() => {
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
return null;
}, [data?.user]);
const userSettings = getSettings();
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token) {
fetcher.submit({ token: event.token as string }, { method: "post" });
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
fetcher.submit({}, { method: "POST", action: "/end-session" });
return;
}
if (typeof event.error === "string") {
toast.error(event.error);
} else {
toast.error(event.message);
}
return;
}
if (isErrorObservation(event)) {
dispatch(
addErrorMessage({
id: event.extras?.error_id,
message: event.message,
}),
);
return;
}
handleAssistantMessage(event);
}, [events.length]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
const initialQuery = initialQueryRef.current;
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
if (ghToken && repo) {
send(getCloneRepoCommand(ghToken, repo));
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
else if (importedProjectZip) {
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
}
if (initialQuery) {
if (additionalInfo) {
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(initialQuery, files);
}
dispatch(clearFiles()); // reset selected files
initialQueryRef.current = null;
}
}
if (status === WsClientProviderStatus.OPENING && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
}),
);
}
if (status === WsClientProviderStatus.STOPPED) {
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
React.useEffect(() => {
if (runtimeActive && userId && ghToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(ghToken));
}
}, [userId, ghToken, runtimeActive]);
React.useEffect(() => {
(async () => {
if (runtimeActive && importedProjectZip) {
// upload files action
try {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
await OpenHands.uploadFiles([file]);
dispatch(setImportedProjectZip(null));
} catch (error) {
toast.error("Failed to upload project files.");
}
}
})();
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
if (userSettings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [userSettings.LLM_API_KEY]);
return children;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import CloseIcon from "#/assets/close.svg?react"; import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
interface ImagePreviewProps { interface ImagePreviewProps {
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react"; import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
import ModalBody from "./ModalBody"; import ModalBody from "./ModalBody";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
@@ -2,17 +2,17 @@ import React from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import posthog from "posthog-js"; import posthog from "posthog-js";
import EllipsisH from "#/assets/ellipsis-h.svg?react"; import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { ModalBackdrop } from "../modals/modal-backdrop"; import { ModalBackdrop } from "../modals/modal-backdrop";
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal"; import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
import { addUserMessage } from "#/state/chatSlice"; import { addUserMessage } from "#/state/chatSlice";
import { useSocket } from "#/context/socket";
import { createChatMessage } from "#/services/chatService"; import { createChatMessage } from "#/services/chatService";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu"; import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder"; import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details"; import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace"; import { downloadWorkspace } from "#/utils/download-workspace";
import { LoadingSpinner } from "../modals/LoadingProject"; import { LoadingSpinner } from "../modals/LoadingProject";
import { useWsClient } from "#/context/ws-client-provider";
interface ProjectMenuCardProps { interface ProjectMenuCardProps {
isConnectedToGitHub: boolean; isConnectedToGitHub: boolean;
@@ -27,7 +27,7 @@ export function ProjectMenuCard({
isConnectedToGitHub, isConnectedToGitHub,
githubData, githubData,
}: ProjectMenuCardProps) { }: ProjectMenuCardProps) {
const { send } = useSocket(); const { send } = useWsClient();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false); const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
@@ -43,10 +43,7 @@ export function ProjectMenuCard({
posthog.capture("push_to_github_button_clicked"); posthog.capture("push_to_github_button_clicked");
const rawEvent = { const rawEvent = {
content: ` content: `
Let's push the code to GitHub. Please push the changes to GitHub and open a pull request.
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
Commit any changes and push them to the remote repository.
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
`, `,
imageUrls: [], imageUrls: [],
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
import CloudConnection from "#/assets/cloud-connection.svg?react"; import CloudConnection from "#/icons/cloud-connection.svg?react";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsPlaceholderProps { interface ProjectMenuDetailsPlaceholderProps {
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ExternalLinkIcon from "#/assets/external-link.svg?react"; import ExternalLinkIcon from "#/icons/external-link.svg?react";
import { formatTimeDelta } from "#/utils/format-time-delta"; import { formatTimeDelta } from "#/utils/format-time-delta";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
@@ -1,4 +1,4 @@
import ArrowSendIcon from "#/assets/arrow-send.svg?react"; import ArrowSendIcon from "#/icons/arrow-send.svg?react";
interface ScrollToBottomButtonProps { interface ScrollToBottomButtonProps {
onClick: () => void; onClick: () => void;
@@ -1,5 +1,5 @@
import Lightbulb from "#/assets/lightbulb.svg?react"; import Lightbulb from "#/icons/lightbulb.svg?react";
import Refresh from "#/assets/refresh.svg?react"; import Refresh from "#/icons/refresh.svg?react";
interface SuggestionBubbleProps { interface SuggestionBubbleProps {
suggestion: string; suggestion: string;
@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react"; import Clip from "#/icons/clip.svg?react";
interface UploadImageInputProps { interface UploadImageInputProps {
onUpload: (files: File[]) => void; onUpload: (files: File[]) => void;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LoadingSpinner } from "./modals/LoadingProject"; import { LoadingSpinner } from "./modals/LoadingProject";
import DefaultUserAvatar from "#/assets/default-user.svg?react"; import DefaultUserAvatar from "#/icons/default-user.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
interface UserAvatarProps { interface UserAvatarProps {
-146
View File
@@ -1,146 +0,0 @@
import React from "react";
import { Data } from "ws";
import posthog from "posthog-js";
import EventLogger from "#/utils/event-logger";
interface WebSocketClientOptions {
token: string | null;
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent<Data>) => void;
onError?: (event: Event) => void;
onClose?: (event: Event) => void;
}
interface WebSocketContextType {
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
start: (options?: WebSocketClientOptions) => void;
stop: () => void;
setRuntimeIsInitialized: () => void;
runtimeActive: boolean;
isConnected: boolean;
events: Record<string, unknown>[];
}
const SocketContext = React.createContext<WebSocketContextType | undefined>(
undefined,
);
interface SocketProviderProps {
children: React.ReactNode;
}
function SocketProvider({ children }: SocketProviderProps) {
const wsRef = React.useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = React.useState(false);
const [runtimeActive, setRuntimeActive] = React.useState(false);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const setRuntimeIsInitialized = () => {
setRuntimeActive(true);
};
const start = React.useCallback((options?: WebSocketClientOptions): void => {
if (wsRef.current) {
EventLogger.warning(
"WebSocket connection is already established, but a new one is starting anyways.",
);
}
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated
const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB";
const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
sessionToken,
ghToken,
]);
ws.addEventListener("open", (event) => {
posthog.capture("socket_opened");
setIsConnected(true);
options?.onOpen?.(event);
});
ws.addEventListener("message", (event) => {
EventLogger.message(event);
setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]);
options?.onMessage?.(event);
});
ws.addEventListener("error", (event) => {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
options?.onError?.(event);
});
ws.addEventListener("close", (event) => {
posthog.capture("socket_closed");
EventLogger.event(event, "SOCKET CLOSE");
setIsConnected(false);
setRuntimeActive(false);
wsRef.current = null;
options?.onClose?.(event);
});
wsRef.current = ws;
}, []);
const stop = React.useCallback((): void => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}, []);
const send = React.useCallback(
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]);
wsRef.current.send(data);
},
[],
);
const value = React.useMemo(
() => ({
send,
start,
stop,
setRuntimeIsInitialized,
runtimeActive,
isConnected,
events,
}),
[
send,
start,
stop,
setRuntimeIsInitialized,
runtimeActive,
isConnected,
events,
],
);
return (
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
);
}
function useSocket() {
const context = React.useContext(SocketContext);
if (context === undefined) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
}
export { SocketProvider, useSocket };
+175
View File
@@ -0,0 +1,175 @@
import posthog from "posthog-js";
import React from "react";
import { Settings } from "#/services/settings";
import ActionType from "#/types/ActionType";
import EventLogger from "#/utils/event-logger";
import AgentState from "#/types/AgentState";
export enum WsClientProviderStatus {
STOPPED,
OPENING,
ACTIVE,
ERROR,
}
interface UseWsClient {
status: WsClientProviderStatus;
events: Record<string, unknown>[];
send: (event: Record<string, unknown>) => void;
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.STOPPED,
events: [],
send: () => {
throw new Error("not connected");
},
});
interface WsClientProviderProps {
enabled: boolean;
token: string | null;
ghToken: string | null;
settings: Settings | null;
}
export function WsClientProvider({
enabled,
token,
ghToken,
settings,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const wsRef = React.useRef<WebSocket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
function send(event: Record<string, unknown>) {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
wsRef.current.send(JSON.stringify(event));
}
function handleOpen() {
setStatus(WsClientProviderStatus.OPENING);
const initEvent = {
action: ActionType.INIT,
args: settings,
};
send(initEvent);
}
function handleMessage(messageEvent: MessageEvent) {
const event = JSON.parse(messageEvent.data);
setEvents((prevEvents) => [...prevEvents, event]);
if (event.extras?.agent_state === AgentState.INIT) {
setStatus(WsClientProviderStatus.ACTIVE);
}
if (
status !== WsClientProviderStatus.ACTIVE &&
event?.observation === "error"
) {
setStatus(WsClientProviderStatus.ERROR);
}
}
function handleClose() {
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
wsRef.current = null;
}
function handleError(event: Event) {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
setStatus(WsClientProviderStatus.ERROR);
}
// Connect websocket
React.useEffect(() => {
let ws = wsRef.current;
// If disabled close any existing websockets...
if (!enabled) {
if (ws) {
ws.close();
}
wsRef.current = null;
return () => {};
}
// If there is no websocket or the tokens have changed or the current websocket is closed,
// create a new one
if (
!ws ||
(tokenRef.current && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current ||
ws.readyState === WebSocket.CLOSED ||
ws.readyState === WebSocket.CLOSING
) {
ws?.close();
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
token || "NO_JWT",
ghToken || "NO_GITHUB",
]);
}
ws.addEventListener("open", handleOpen);
ws.addEventListener("message", handleMessage);
ws.addEventListener("error", handleError);
ws.addEventListener("close", handleClose);
wsRef.current = ws;
tokenRef.current = token;
ghTokenRef.current = ghToken;
return () => {
ws.removeEventListener("open", handleOpen);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.removeEventListener("close", handleClose);
};
}, [enabled, token, ghToken]);
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
// before actually closing the socket and cancel the operation if the component gets remounted.
React.useEffect(() => {
const timeout = closeRef.current;
if (timeout != null) {
clearTimeout(timeout);
}
return () => {
closeRef.current = setTimeout(() => {
wsRef.current?.close();
}, 100);
};
}, []);
const value = React.useMemo<UseWsClient>(
() => ({
status,
events,
send,
}),
[status, events],
);
return (
<WsClientContext.Provider value={value}>
{children}
</WsClientContext.Provider>
);
}
export function useWsClient() {
const context = React.useContext(WsClientContext);
return context;
}
+4 -7
View File
@@ -10,7 +10,6 @@ import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { SocketProvider } from "./context/socket";
import "./i18n"; import "./i18n";
import store from "./store"; import store from "./store";
@@ -43,12 +42,10 @@ prepareApp().then(() =>
hydrateRoot( hydrateRoot(
document, document,
<StrictMode> <StrictMode>
<SocketProvider> <Provider store={store}>
<Provider store={store}> <RemixBrowser />
<RemixBrowser /> <PosthogInit />
<PosthogInit /> </Provider>
</Provider>
</SocketProvider>
</StrictMode>, </StrictMode>,
); );
}), }),
+2 -2
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Command } from "#/state/commandSlice"; import { Command } from "#/state/commandSlice";
import { getTerminalCommand } from "#/services/terminalService"; import { getTerminalCommand } from "#/services/terminalService";
import { parseTerminalOutput } from "#/utils/parseTerminalOutput"; import { parseTerminalOutput } from "#/utils/parseTerminalOutput";
import { useSocket } from "#/context/socket"; import { useWsClient } from "#/context/ws-client-provider";
/* /*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component. NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
@@ -15,7 +15,7 @@ export const useTerminal = (
commands: Command[] = [], commands: Command[] = [],
secrets: string[] = [], secrets: string[] = [],
) => { ) => {
const { send } = useSocket(); const { send } = useWsClient();
const terminal = React.useRef<Terminal | null>(null); const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null); const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
+283 -38
View File
@@ -535,7 +535,8 @@
"pt": "Socket não inicializado", "pt": "Socket não inicializado",
"ko-KR": "소켓이 초기화되지 않았습니다", "ko-KR": "소켓이 초기화되지 않았습니다",
"ar": "لم يتم تهيئة Socket", "ar": "لم يتم تهيئة Socket",
"tr": "Soket başlatılmadı" "tr": "Soket başlatılmadı",
"no": "Socket ikke initialisert"
}, },
"EXPLORER$UPLOAD_ERROR_MESSAGE": { "EXPLORER$UPLOAD_ERROR_MESSAGE": {
"en": "Error uploading file", "en": "Error uploading file",
@@ -548,7 +549,8 @@
"pt": "Erro ao fazer upload do arquivo", "pt": "Erro ao fazer upload do arquivo",
"ko-KR": "파일 업로드 중 오류 발생", "ko-KR": "파일 업로드 중 오류 발생",
"ar": "خطأ في تحميل الملف", "ar": "خطأ في تحميل الملف",
"tr": "Dosya yüklenirken hata oluştu" "tr": "Dosya yüklenirken hata oluştu",
"no": "Feil ved opplasting av fil"
}, },
"EXPLORER$LABEL_DROP_FILES": { "EXPLORER$LABEL_DROP_FILES": {
"en": "Drop files here", "en": "Drop files here",
@@ -557,6 +559,7 @@
"zh-TW": "將檔案拖曳至此", "zh-TW": "將檔案拖曳至此",
"es": "Suelta los archivos aquí", "es": "Suelta los archivos aquí",
"fr": "Déposez les fichiers ici", "fr": "Déposez les fichiers ici",
"no": "Slipp filer her",
"it": "Trascina i file qui", "it": "Trascina i file qui",
"pt": "Solte os arquivos aqui", "pt": "Solte os arquivos aqui",
"ko-KR": "파일을 여기에 놓으세요", "ko-KR": "파일을 여기에 놓으세요",
@@ -574,7 +577,8 @@
"pt": "Espaço de trabalho", "pt": "Espaço de trabalho",
"ko-KR": "작업 공간", "ko-KR": "작업 공간",
"ar": "مساحة العمل", "ar": "مساحة العمل",
"tr": "Çalışma alanı" "tr": "Çalışma alanı",
"no": "Arbeidsområde"
}, },
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": { "EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
"en": "No files in workspace", "en": "No files in workspace",
@@ -587,7 +591,8 @@
"pt": "Nenhum arquivo no espaço de trabalho", "pt": "Nenhum arquivo no espaço de trabalho",
"ko-KR": "작업 공간에 파일이 없습니다", "ko-KR": "작업 공간에 파일이 없습니다",
"ar": "لا توجد ملفات في مساحة العمل", "ar": "لا توجد ملفات في مساحة العمل",
"tr": "Çalışma alanında dosya yok" "tr": "Çalışma alanında dosya yok",
"no": "Ingen filer i arbeidsområdet"
}, },
"EXPLORER$LOADING_WORKSPACE_MESSAGE": { "EXPLORER$LOADING_WORKSPACE_MESSAGE": {
"en": "Loading workspace...", "en": "Loading workspace...",
@@ -600,7 +605,8 @@
"pt": "Carregando espaço de trabalho...", "pt": "Carregando espaço de trabalho...",
"ko-KR": "작업 공간 로딩 중...", "ko-KR": "작업 공간 로딩 중...",
"ar": "جارٍ تحميل مساحة العمل...", "ar": "جارٍ تحميل مساحة العمل...",
"tr": "Çalışma alanı yükleniyor..." "tr": "Çalışma alanı yükleniyor...",
"no": "Laster arbeidsområde..."
}, },
"EXPLORER$REFRESH_ERROR_MESSAGE": { "EXPLORER$REFRESH_ERROR_MESSAGE": {
"en": "Error refreshing workspace", "en": "Error refreshing workspace",
@@ -613,7 +619,8 @@
"pt": "Erro ao atualizar o espaço de trabalho", "pt": "Erro ao atualizar o espaço de trabalho",
"ko-KR": "작업 공간 새로 고침 오류", "ko-KR": "작업 공간 새로 고침 오류",
"ar": "خطأ في تحديث مساحة العمل", "ar": "خطأ في تحديث مساحة العمل",
"tr": "Çalışma alanı yenilenirken hata oluştu" "tr": "Çalışma alanı yenilenirken hata oluştu",
"no": "Feil ved oppdatering av arbeidsområde"
}, },
"EXPLORER$UPLOAD_SUCCESS_MESSAGE": { "EXPLORER$UPLOAD_SUCCESS_MESSAGE": {
"en": "Successfully uploaded {{count}} file(s)", "en": "Successfully uploaded {{count}} file(s)",
@@ -626,7 +633,8 @@
"pt": "{{count}} arquivo(s) carregado(s) com sucesso", "pt": "{{count}} arquivo(s) carregado(s) com sucesso",
"ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다", "ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다",
"ar": "تم تحميل {{count}} ملف (ملفات) بنجاح", "ar": "تم تحميل {{count}} ملف (ملفات) بنجاح",
"tr": "{{count}} dosya başarıyla yüklendi" "tr": "{{count}} dosya başarıyla yüklendi",
"no": "Lastet opp {{count}} fil(er) vellykket"
}, },
"EXPLORER$NO_FILES_UPLOADED_MESSAGE": { "EXPLORER$NO_FILES_UPLOADED_MESSAGE": {
"en": "No files were uploaded", "en": "No files were uploaded",
@@ -639,7 +647,8 @@
"pt": "Nenhum arquivo foi carregado", "pt": "Nenhum arquivo foi carregado",
"ko-KR": "업로드된 파일이 없습니다", "ko-KR": "업로드된 파일이 없습니다",
"ar": "لم يتم تحميل أي ملفات", "ar": "لم يتم تحميل أي ملفات",
"tr": "Hiçbir dosya yüklenmedi" "tr": "Hiçbir dosya yüklenmedi",
"no": "Ingen filer ble lastet opp"
}, },
"EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": { "EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": {
"en": "{{count}} file(s) were skipped during upload", "en": "{{count}} file(s) were skipped during upload",
@@ -652,7 +661,8 @@
"pt": "{{count}} arquivo(s) foram ignorados durante o upload", "pt": "{{count}} arquivo(s) foram ignorados durante o upload",
"ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다", "ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다",
"ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل", "ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل",
"tr": "Yükleme sırasında {{count}} dosya atlandı" "tr": "Yükleme sırasında {{count}} dosya atlandı",
"no": "{{count}} fil(er) ble hoppet over under opplasting"
}, },
"EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": { "EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": {
"en": "Unexpected response structure from server", "en": "Unexpected response structure from server",
@@ -665,7 +675,8 @@
"pt": "Estrutura de resposta inesperada do servidor", "pt": "Estrutura de resposta inesperada do servidor",
"ko-KR": "서버로부터 예상치 못한 응답 구조", "ko-KR": "서버로부터 예상치 못한 응답 구조",
"ar": "بنية استجابة غير متوقعة من الخادم", "ar": "بنية استجابة غير متوقعة من الخادم",
"tr": "Sunucudan beklenmeyen yanıt yapısı" "tr": "Sunucudan beklenmeyen yanıt yapısı",
"no": "Uventet responsstruktur fra serveren"
}, },
"LOAD_SESSION$MODAL_TITLE": { "LOAD_SESSION$MODAL_TITLE": {
"en": "Return to existing session?", "en": "Return to existing session?",
@@ -799,95 +810,325 @@
}, },
"FEEDBACK$EMAIL_PLACEHOLDER": { "FEEDBACK$EMAIL_PLACEHOLDER": {
"en": "Enter your email address", "en": "Enter your email address",
"es": "Ingresa tu correo electrónico" "es": "Ingresa tu correo electrónico",
"zh-CN": "输入您的电子邮件地址",
"zh-TW": "輸入您的電子郵件地址",
"ko-KR": "이메일 주소를 입력하세요",
"no": "Skriv inn din e-postadresse",
"ar": "أدخل عنوان بريدك الإلكتروني",
"de": "Geben Sie Ihre E-Mail-Adresse ein",
"fr": "Entrez votre adresse e-mail",
"it": "Inserisci il tuo indirizzo email",
"pt": "Digite seu endereço de e-mail",
"tr": "E-posta adresinizi girin"
}, },
"FEEDBACK$PASSWORD_COPIED_MESSAGE": { "FEEDBACK$PASSWORD_COPIED_MESSAGE": {
"en": "Password copied to clipboard.", "en": "Password copied to clipboard.",
"es": "Contraseña copiada al portapapeles." "es": "Contraseña copiada al portapapeles.",
"zh-CN": "密码已复制到剪贴板。",
"zh-TW": "密碼已複製到剪貼板。",
"ko-KR": "비밀번호가 클립보드에 복사되었습니다.",
"no": "Passord kopiert til utklippstavlen.",
"ar": "تم نسخ كلمة المرور إلى الحافظة.",
"de": "Passwort in die Zwischenablage kopiert.",
"fr": "Mot de passe copié dans le presse-papiers.",
"it": "Password copiata negli appunti.",
"pt": "Senha copiada para a área de transferência.",
"tr": "Parola panoya kopyalandı."
}, },
"FEEDBACK$GO_TO_FEEDBACK": { "FEEDBACK$GO_TO_FEEDBACK": {
"en": "Go to shared feedback", "en": "Go to shared feedback",
"es": "Ir a feedback compartido" "es": "Ir a feedback compartido",
"zh-CN": "转到共享反馈",
"zh-TW": "前往共享反饋",
"ko-KR": "공유된 피드백으로 이동",
"no": "Gå til delt tilbakemelding",
"ar": "الذهاب إلى التعليقات المشتركة",
"de": "Zum geteilten Feedback gehen",
"fr": "Aller aux commentaires partagés",
"it": "Vai al feedback condiviso",
"pt": "Ir para feedback compartilhado",
"tr": "Paylaşılan geri bildirimlere git"
}, },
"FEEDBACK$PASSWORD": { "FEEDBACK$PASSWORD": {
"en": "Password:", "en": "Password:",
"es": "Contraseña:" "es": "Contraseña:",
"zh-CN": "密码:",
"zh-TW": "密碼:",
"ko-KR": "비밀번호:",
"no": "Passord:",
"ar": "كلمة المرور:",
"de": "Passwort:",
"fr": "Mot de passe :",
"it": "Password:",
"pt": "Senha:",
"tr": "Parola:"
}, },
"FEEDBACK$INVALID_EMAIL_FORMAT": { "FEEDBACK$INVALID_EMAIL_FORMAT": {
"en": "Invalid email format", "en": "Invalid email format",
"es": "Formato de correo inválido" "es": "Formato de correo inválido",
"zh-CN": "无效的电子邮件格式",
"zh-TW": "無效的電子郵件格式",
"ko-KR": "잘못된 이메일 형식",
"no": "Ugyldig e-postformat",
"ar": "تنسيق البريد الإلكتروني غير صالح",
"de": "Ungültiges E-Mail-Format",
"fr": "Format d'e-mail invalide",
"it": "Formato email non valido",
"pt": "Formato de e-mail inválido",
"tr": "Geçersiz e-posta biçimi"
}, },
"FEEDBACK$FAILED_TO_SHARE": { "FEEDBACK$FAILED_TO_SHARE": {
"en": "Failed to share, please contact the developers:", "en": "Failed to share, please contact the developers:",
"es": "Error al compartir, por favor contacta con los desarrolladores:" "es": "Error al compartir, por favor contacta con los desarrolladores:",
"zh-CN": "分享失败,请联系开发人员:",
"zh-TW": "分享失敗,請聯繫開發人員:",
"ko-KR": "공유 실패, 개발자에게 문의하세요:",
"no": "Deling mislyktes, vennligst kontakt utviklerne:",
"ar": "فشل المشاركة، يرجى الاتصال بالمطورين:",
"de": "Teilen fehlgeschlagen, bitte kontaktieren Sie die Entwickler:",
"fr": "Échec du partage, veuillez contacter les développeurs :",
"it": "Condivisione fallita, contattare gli sviluppatori:",
"pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:",
"tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:"
}, },
"FEEDBACK$COPY_LABEL": { "FEEDBACK$COPY_LABEL": {
"en": "Copy", "en": "Copy",
"es": "Copiar" "es": "Copiar",
"zh-CN": "复制",
"zh-TW": "複製",
"ko-KR": "복사",
"no": "Kopier",
"ar": "نسخ",
"de": "Kopieren",
"fr": "Copier",
"it": "Copia",
"pt": "Copiar",
"tr": "Kopyala"
}, },
"FEEDBACK$SHARING_SETTINGS_LABEL": { "FEEDBACK$SHARING_SETTINGS_LABEL": {
"en": "Sharing settings", "en": "Sharing settings",
"es": "Configuración de compartir" "es": "Configuración de compartir",
"zh-CN": "共享设置",
"zh-TW": "共享設定",
"ko-KR": "공유 설정",
"no": "Delingsinnstillinger",
"ar": "إعدادات المشاركة",
"de": "Freigabeeinstellungen",
"fr": "Paramètres de partage",
"it": "Impostazioni di condivisione",
"pt": "Configurações de compartilhamento",
"tr": "Paylaşım ayarları"
}, },
"SECURITY$UNKNOWN_ANALYZER_LABEL":{ "SECURITY$UNKNOWN_ANALYZER_LABEL":{
"en": "Unknown security analyzer chosen", "en": "Unknown security analyzer chosen",
"es": "Analizador de seguridad desconocido" "es": "Analizador de seguridad desconocido",
"zh-CN": "选择了未知的安全分析器",
"zh-TW": "選擇了未知的安全分析器",
"ko-KR": "알 수 없는 보안 분석기가 선택되었습니다",
"no": "Ukjent sikkerhetsanalysator valgt",
"ar": "تم اختيار محلل أمان غير معروف",
"de": "Unbekannter Sicherheitsanalysator ausgewählt",
"fr": "Analyseur de sécurité inconnu choisi",
"it": "Analizzatore di sicurezza sconosciuto selezionato",
"pt": "Analisador de segurança desconhecido escolhido",
"tr": "Bilinmeyen güvenlik analizörü seçildi"
}, },
"INVARIANT$UPDATE_POLICY_LABEL": { "INVARIANT$UPDATE_POLICY_LABEL": {
"en": "Update Policy", "en": "Update Policy",
"es": "Actualizar política" "es": "Actualizar política",
"zh-CN": "更新策略",
"zh-TW": "更新策略",
"ko-KR": "정책 업데이트",
"no": "Oppdater policy",
"ar": "تحديث السياسة",
"de": "Richtlinie aktualisieren",
"fr": "Mettre à jour la politique",
"it": "Aggiorna policy",
"pt": "Atualizar política",
"tr": "İlkeyi güncelle"
}, },
"INVARIANT$UPDATE_SETTINGS_LABEL": { "INVARIANT$UPDATE_SETTINGS_LABEL": {
"en": "Update Settings", "en": "Update Settings",
"es": "Actualizar configuración" "es": "Actualizar configuración",
"zh-CN": "更新设置",
"zh-TW": "更新設定",
"ko-KR": "설정 업데이트",
"no": "Oppdater innstillinger",
"ar": "تحديث الإعدادات",
"de": "Einstellungen aktualisieren",
"fr": "Mettre à jour les paramètres",
"it": "Aggiorna impostazioni",
"pt": "Atualizar configurações",
"tr": "Ayarları güncelle"
}, },
"INVARIANT$SETTINGS_LABEL": { "INVARIANT$SETTINGS_LABEL": {
"en": "Settings", "en": "Settings",
"es": "Configuración" "es": "Configuración",
"zh-CN": "设置",
"zh-TW": "設定",
"ko-KR": "설정",
"no": "Innstillinger",
"ar": "الإعدادات",
"de": "Einstellungen",
"fr": "Paramètres",
"it": "Impostazioni",
"pt": "Configurações",
"tr": "Ayarlar"
}, },
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": { "INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
"en": "Ask for user confirmation on risk severity:", "en": "Ask for user confirmation on risk severity:",
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:" "es": "Preguntar por confirmación del usuario sobre severidad del riesgo:",
"zh-CN": "询问用户确认风险等级:",
"zh-TW": "詢問用戶確認風險等級:",
"ko-KR": "위험 심각도에 대한 사용자 확인 요청:",
"no": "Be om brukerbekreftelse på risikoalvorlighet:",
"ar": "اطلب تأكيد المستخدم على مستوى الخطورة:",
"de": "Nach Benutzerbestätigung für Risikoschweregrad fragen:",
"fr": "Demander la confirmation de l'utilisateur sur la gravité du risque :",
"it": "Chiedi conferma all'utente sulla gravità del rischio:",
"pt": "Solicitar confirmação do usuário sobre a gravidade do risco:",
"tr": "Risk şiddeti için kullanıcı onayı iste:"
}, },
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": { "INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
"en": "Don't ask for confirmation", "en": "Don't ask for confirmation",
"es": "No solicitar confirmación" "es": "No solicitar confirmación",
"zh-CN": "不要请求确认",
"zh-TW": "不要請求確認",
"ko-KR": "확인 요청하지 않음",
"no": "Ikke spør om bekreftelse",
"ar": "لا تطلب التأكيد",
"de": "Nicht nach Bestätigung fragen",
"fr": "Ne pas demander de confirmation",
"it": "Non chiedere conferma",
"pt": "Não solicitar confirmação",
"tr": "Onay isteme"
}, },
"INVARIANT$INVARIANT_ANALYZER_LABEL": { "INVARIANT$INVARIANT_ANALYZER_LABEL": {
"en": "Invariant Analyzer", "en": "Invariant Analyzer",
"es": "Analizador de invariantes" "es": "Analizador de invariantes",
"zh-CN": "不变量分析器",
"zh-TW": "不變量分析器",
"ko-KR": "불변성 분석기",
"no": "Invariant-analysator",
"ar": "محلل الثوابت",
"de": "Invarianten-Analysator",
"fr": "Analyseur d'invariants",
"it": "Analizzatore di invarianti",
"pt": "Analisador de invariantes",
"tr": "Değişmez Analizörü"
}, },
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": { "INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.", "en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.",
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad." "es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad.",
"zh-CN": "不变量分析器持续监控您的 OpenHands 代理的安全问题。",
"zh-TW": "不變量分析器持續監控您的 OpenHands 代理的安全問題。",
"ko-KR": "불변성 분석기는 OpenHands 에이전트의 보안 문제를 지속적으로 모니터링합니다.",
"no": "Invariant-analysatoren overvåker kontinuerlig OpenHands-agenten din for sikkerhetsproblemer.",
"ar": "يراقب محلل الثوابت وكيل OpenHands الخاص بك باستمرار للتحقق من المشاكل الأمنية.",
"de": "Der Invarianten-Analysator überwacht kontinuierlich Ihren OpenHands-Agenten auf Sicherheitsprobleme.",
"fr": "L'analyseur d'invariants surveille en permanence votre agent OpenHands pour détecter les problèmes de sécurité.",
"it": "L'analizzatore di invarianti monitora continuamente il tuo agente OpenHands per problemi di sicurezza.",
"pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.",
"tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler."
}, },
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": { "INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
"en": "Click to learn more", "en": "Click to learn more",
"es": "Clic para aprender más" "es": "Clic para aprender más",
"zh-CN": "点击了解更多",
"zh-TW": "點擊了解更多",
"ko-KR": "자세히 알아보기",
"no": "Klikk for å lære mer",
"ar": "انقر لمعرفة المزيد",
"de": "Klicken Sie, um mehr zu erfahren",
"fr": "Cliquez pour en savoir plus",
"it": "Clicca per saperne di più",
"pt": "Clique para saber mais",
"tr": "Daha fazla bilgi için tıklayın"
}, },
"INVARIANT$POLICY_LABEL": { "INVARIANT$POLICY_LABEL": {
"en": "Policy", "en": "Policy",
"es": "Política" "es": "Política",
"zh-CN": "策略",
"zh-TW": "策略",
"ko-KR": "정책",
"no": "Policy",
"ar": "السياسة",
"de": "Richtlinie",
"fr": "Politique",
"it": "Policy",
"pt": "Política",
"tr": "İlke"
}, },
"INVARIANT$LOG_LABEL": { "INVARIANT$LOG_LABEL": {
"en": "Logs", "en": "Logs",
"es": "Logs" "es": "Logs",
"zh-CN": "日志",
"zh-TW": "日誌",
"ko-KR": "로그",
"no": "Logger",
"ar": "السجلات",
"de": "Protokolle",
"fr": "Journaux",
"it": "Log",
"pt": "Logs",
"tr": "Günlükler"
}, },
"INVARIANT$EXPORT_TRACE_LABEL": { "INVARIANT$EXPORT_TRACE_LABEL": {
"en": "Export Trace", "en": "Export Trace",
"es": "Exportar traza" "es": "Exportar traza",
"zh-CN": "导出跟踪",
"zh-TW": "匯出追蹤",
"ko-KR": "추적 내보내기",
"no": "Eksporter sporing",
"ar": "تصدير التتبع",
"de": "Ablaufverfolgung exportieren",
"fr": "Exporter la trace",
"it": "Esporta traccia",
"pt": "Exportar rastreamento",
"tr": "İzlemeyi dışa aktar"
}, },
"INVARIANT$TRACE_EXPORTED_MESSAGE": { "INVARIANT$TRACE_EXPORTED_MESSAGE": {
"en": "Trace exported", "en": "Trace exported",
"es": "Traza exportada" "es": "Traza exportada",
"zh-CN": "跟踪已导出",
"zh-TW": "追蹤已匯出",
"ko-KR": "추적 내보내기 완료",
"no": "Sporing eksportert",
"ar": "تم تصدير التتبع",
"de": "Ablaufverfolgung exportiert",
"fr": "Trace exportée",
"it": "Traccia esportata",
"pt": "Rastreamento exportado",
"tr": "İzleme dışa aktarıldı"
}, },
"INVARIANT$POLICY_UPDATED_MESSAGE": { "INVARIANT$POLICY_UPDATED_MESSAGE": {
"en": "Policy updated", "en": "Policy updated",
"es": "Política actualizada" "es": "Política actualizada",
"zh-CN": "策略已更新",
"zh-TW": "策略已更新",
"ko-KR": "정책이 업데이트되었습니다",
"no": "Policy oppdatert",
"ar": "تم تحديث السياسة",
"de": "Richtlinie aktualisiert",
"fr": "Politique mise à jour",
"it": "Policy aggiornata",
"pt": "Política atualizada",
"tr": "İlke güncellendi"
}, },
"INVARIANT$SETTINGS_UPDATED_MESSAGE": { "INVARIANT$SETTINGS_UPDATED_MESSAGE": {
"en": "Settings updated", "en": "Settings updated",
"es": "Configuración actualizada" "es": "Configuración actualizada",
"zh-CN": "设置已更新",
"zh-TW": "設定已更新",
"ko-KR": "설정이 업데이트되었습니다",
"no": "Innstillinger oppdatert",
"ar": "تم تحديث الإعدادات",
"de": "Einstellungen aktualisiert",
"fr": "Paramètres mis à jour",
"it": "Impostazioni aggiornate",
"pt": "Configurações atualizadas",
"tr": "Ayarlar güncellendi"
}, },
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": { "CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
"en": "Starting up!", "en": "Starting up!",
@@ -1276,7 +1517,8 @@
"pt": "Conversa de chat", "pt": "Conversa de chat",
"es": "Conversación de chat", "es": "Conversación de chat",
"ar": "محادثة تلقيم", "ar": "محادثة تلقيم",
"fr": "Conversation de chat" "fr": "Conversation de chat",
"tr": "Sohbet Konuşması"
}, },
"CHAT_INTERFACE$UNKNOWN_SENDER": { "CHAT_INTERFACE$UNKNOWN_SENDER": {
"en": "Unknown", "en": "Unknown",
@@ -1531,10 +1773,12 @@
"tr": "Özel" "tr": "Özel"
}, },
"ERROR_MESSAGE$SHOW_DETAILS": { "ERROR_MESSAGE$SHOW_DETAILS": {
"en": "Show details" "en": "Show details",
"es": "Mostrar detalles"
}, },
"ERROR_MESSAGE$HIDE_DETAILS": { "ERROR_MESSAGE$HIDE_DETAILS": {
"en": "Hide details" "en": "Hide details",
"es": "Ocultar detalles"
}, },
"STATUS$STARTING_RUNTIME": { "STATUS$STARTING_RUNTIME": {
"en": "Starting Runtime...", "en": "Starting Runtime...",
@@ -1616,7 +1860,7 @@
}, },
"ACCOUNT_SETTINGS_MODAL$CLOSE":{ "ACCOUNT_SETTINGS_MODAL$CLOSE":{
"en": "Close", "en": "Close",
"es": "" "es": "Cerrar"
}, },
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{ "ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
"en": "GitHub token is invalid. Please try again.", "en": "GitHub token is invalid. Please try again.",
@@ -1735,7 +1979,8 @@
"es":"atrás" "es":"atrás"
}, },
"STATUS$ERROR_LLM_AUTHENTICATION": { "STATUS$ERROR_LLM_AUTHENTICATION": {
"en": "Error authenticating with the LLM provider. Please check your API key" "en": "Error authenticating with the LLM provider. Please check your API key",
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
}, },
"STATUS$ERROR_RUNTIME_DISCONNECTED": { "STATUS$ERROR_RUNTIME_DISCONNECTED": {
"en": "There was an error while connecting to the runtime. Please refresh the page." "en": "There was an error while connecting to the runtime. Please refresh the page."

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 662 B

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 319 B

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 924 B

After

Width:  |  Height:  |  Size: 924 B

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 649 B

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 856 B

Before

Width:  |  Height:  |  Size: 811 B

After

Width:  |  Height:  |  Size: 811 B

@@ -1,4 +1,4 @@
import BuildIt from "#/assets/build-it.svg?react"; import BuildIt from "#/icons/build-it.svg?react";
export function HeroHeading() { export function HeroHeading() {
return ( return (
@@ -29,6 +29,10 @@ function CodeEditorCompoonent({
if (selectedPath && value) modifyFileContent(selectedPath, value); if (selectedPath && value) modifyFileContent(selectedPath, value);
}; };
const isBase64Image = (content: string) => content.startsWith("data:image/");
const isPDF = (content: string) => content.startsWith("data:application/pdf");
const isVideo = (content: string) => content.startsWith("data:video/");
React.useEffect(() => { React.useEffect(() => {
const handleSave = async (event: KeyboardEvent) => { const handleSave = async (event: KeyboardEvent) => {
if (selectedPath && event.metaKey && event.key === "s") { if (selectedPath && event.metaKey && event.key === "s") {
@@ -62,16 +66,40 @@ function CodeEditorCompoonent({
); );
} }
const fileContent = modifiedFiles[selectedPath] || files[selectedPath];
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img src={fileContent} alt={selectedPath} className="object-contain" />
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
return ( return (
<Editor <Editor
data-testid="code-editor" data-testid="code-editor"
path={selectedPath ?? undefined} path={selectedPath ?? undefined}
defaultValue="" defaultValue=""
value={ value={selectedPath ? fileContent : undefined}
selectedPath
? modifiedFiles[selectedPath] || files[selectedPath]
: undefined
}
onMount={onMount} onMount={onMount}
onChange={handleEditorChange} onChange={handleEditorChange}
options={{ readOnly: isReadOnly }} options={{ readOnly: isReadOnly }}
+64 -245
View File
@@ -2,71 +2,29 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react"; import React from "react";
import { import {
Outlet, Outlet,
useFetcher,
useLoaderData, useLoaderData,
json, json,
ClientActionFunctionArgs, ClientActionFunctionArgs,
useRouteLoaderData,
} from "@remix-run/react"; } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch } from "react-redux";
import WebSocket from "ws";
import toast from "react-hot-toast";
import { getSettings } from "#/services/settings"; import { getSettings } from "#/services/settings";
import Security from "../components/modals/security/Security"; import Security from "../components/modals/security/Security";
import { Controls } from "#/components/controls"; import { Controls } from "#/components/controls";
import store, { RootState } from "#/store"; import store from "#/store";
import { Container } from "#/components/container"; import { Container } from "#/components/container";
import ActionType from "#/types/ActionType"; import { clearMessages } from "#/state/chatSlice";
import { handleAssistantMessage } from "#/services/actions";
import {
addErrorMessage,
addUserMessage,
clearMessages,
} from "#/state/chatSlice";
import { useSocket } from "#/context/socket";
import {
getGitHubTokenCommand,
getCloneRepoCommand,
} from "#/services/terminalService";
import { clearTerminal } from "#/state/commandSlice"; import { clearTerminal } from "#/state/commandSlice";
import { useEffectOnce } from "#/utils/use-effect-once"; import { useEffectOnce } from "#/utils/use-effect-once";
import CodeIcon from "#/assets/code.svg?react"; import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/assets/globe.svg?react"; import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/assets/list-type-number.svg?react"; import ListIcon from "#/icons/list-type-number.svg?react";
import { createChatMessage } from "#/services/chatService"; import { clearInitialQuery } from "#/state/initial-query-slice";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github"; import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
import OpenHands from "#/api/open-hands";
import AgentState from "#/types/AgentState";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { clearJupyter } from "#/state/jupyterSlice"; import { clearJupyter } from "#/state/jupyterSlice";
import { FilesProvider } from "#/context/files"; import { FilesProvider } from "#/context/files";
import { ErrorObservation } from "#/types/core/observations";
import { ChatInterface } from "#/components/chat-interface"; import { ChatInterface } from "#/components/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
interface ServerError { import { EventHandler } from "#/components/event-handler";
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
const isAgentStateChange = (
data: object,
): data is { extras: { agent_state: AgentState } } =>
"extras" in data &&
data.extras instanceof Object &&
"agent_state" in data.extras;
export const clientLoader = async () => { export const clientLoader = async () => {
const ghToken = localStorage.getItem("ghToken"); const ghToken = localStorage.getItem("ghToken");
@@ -116,174 +74,26 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
function App() { function App() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector( const { settings, token, ghToken, lastCommit } =
(state: RootState) => state.initalQuery,
);
const { start, send, setRuntimeIsInitialized, runtimeActive } = useSocket();
const { settings, token, ghToken, repo, q, lastCommit } =
useLoaderData<typeof clientLoader>(); useLoaderData<typeof clientLoader>();
const fetcher = useFetcher();
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const secrets = React.useMemo( const secrets = React.useMemo(
() => [ghToken, token].filter((secret) => secret !== null), () => [ghToken, token].filter((secret) => secret !== null),
[ghToken, token], [ghToken, token],
); );
// To avoid re-rendering the component when the user object changes, we memoize the user ID.
// We use this to ensure the github token is valid before exporting it to the terminal.
const userId = React.useMemo(() => {
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
return null;
}, [data?.user]);
const Terminal = React.useMemo( const Terminal = React.useMemo(
() => React.lazy(() => import("../components/terminal/Terminal")), () => React.lazy(() => import("../components/terminal/Terminal")),
[], [],
); );
const addIntialQueryToChat = (
query: string,
base64Files: string[],
timestamp = new Date().toISOString(),
) => {
dispatch(
addUserMessage({
content: query,
imageUrls: base64Files,
timestamp,
}),
);
};
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const handleOpen = React.useCallback(() => {
const initEvent = {
action: ActionType.INIT,
args: settings,
};
send(JSON.stringify(initEvent));
// display query in UI, but don't send it to the server
if (q) addIntialQueryToChat(q, files);
}, [settings]);
const handleMessage = React.useCallback(
(message: MessageEvent<WebSocket.Data>) => {
// set token received from the server
const parsed = JSON.parse(message.data.toString());
if ("token" in parsed) {
fetcher.submit({ token: parsed.token }, { method: "post" });
return;
}
if (isServerError(parsed)) {
if (parsed.error_code === 401) {
toast.error("Session expired.");
fetcher.submit({}, { method: "POST", action: "/end-session" });
return;
}
if (typeof parsed.error === "string") {
toast.error(parsed.error);
} else {
toast.error(parsed.message);
}
return;
}
if (isErrorObservation(parsed)) {
dispatch(
addErrorMessage({
id: parsed.extras?.error_id,
message: parsed.message,
}),
);
return;
}
handleAssistantMessage(message.data.toString());
// handle first time connection
if (
isAgentStateChange(parsed) &&
parsed.extras.agent_state === AgentState.INIT
) {
setRuntimeIsInitialized();
// handle new session
if (!token) {
let additionalInfo = "";
if (ghToken && repo) {
send(getCloneRepoCommand(ghToken, repo));
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
else if (importedProjectZip) {
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
}
if (q) {
if (additionalInfo) {
sendInitialQuery(`${q}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(q, files);
}
dispatch(clearFiles()); // reset selected files
}
}
}
},
[token, ghToken, repo, q, files],
);
const startSocketConnection = React.useCallback(() => {
start({
token,
onOpen: handleOpen,
onMessage: handleMessage,
});
}, [token, handleOpen, handleMessage]);
useEffectOnce(() => { useEffectOnce(() => {
// clear and restart the socket connection
dispatch(clearMessages()); dispatch(clearMessages());
dispatch(clearTerminal()); dispatch(clearTerminal());
dispatch(clearJupyter()); dispatch(clearJupyter());
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
startSocketConnection();
}); });
React.useEffect(() => {
if (runtimeActive && userId && ghToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(ghToken));
}
}, [userId, ghToken, runtimeActive]);
React.useEffect(() => {
(async () => {
if (runtimeActive && importedProjectZip) {
// upload files action
try {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
await OpenHands.uploadFiles([file]);
dispatch(setImportedProjectZip(null));
} catch (error) {
toast.error("Failed to upload project files.");
}
}
})();
}, [runtimeActive, importedProjectZip]);
const { const {
isOpen: securityModalIsOpen, isOpen: securityModalIsOpen,
onOpen: onSecurityModalOpen, onOpen: onSecurityModalOpen,
@@ -291,53 +101,62 @@ function App() {
} = useDisclosure(); } = useDisclosure();
return ( return (
<div className="flex flex-col h-full gap-3"> <WsClientProvider
<div className="flex h-full overflow-auto gap-3"> enabled
<Container className="w-[390px] max-h-full relative"> token={token}
<ChatInterface /> ghToken={ghToken}
</Container> settings={settings}
>
<EventHandler>
<div className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto gap-3">
<Container className="w-[390px] max-h-full relative">
<ChatInterface />
</Container>
<div className="flex flex-col grow gap-3"> <div className="flex flex-col grow gap-3">
<Container <Container
className="h-2/3" className="h-2/3"
labels={[ labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> }, { label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> }, { label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{ {
label: "Browser", label: "Browser",
to: "browser", to: "browser",
icon: <GlobeIcon />, icon: <GlobeIcon />,
isBeta: true, isBeta: true,
}, },
]} ]}
> >
<FilesProvider> <FilesProvider>
<Outlet /> <Outlet />
</FilesProvider> </FilesProvider>
</Container> </Container>
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure {/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */} * that it loads only in the client-side. */}
<Container className="h-1/3 overflow-scroll" label="Terminal"> <Container className="h-1/3 overflow-scroll" label="Terminal">
<React.Suspense fallback={<div className="h-full" />}> <React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} /> <Terminal secrets={secrets} />
</React.Suspense> </React.Suspense>
</Container> </Container>
</div>
</div>
<div className="h-[60px]">
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={lastCommit}
/>
</div>
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
</div> </div>
</div> </EventHandler>
</WsClientProvider>
<div className="h-[60px]">
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={lastCommit}
/>
</div>
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
</div>
); );
} }
+5 -17
View File
@@ -21,12 +21,11 @@ import { DangerModal } from "#/components/modals/confirmation-modals/danger-moda
import { LoadingSpinner } from "#/components/modals/LoadingProject"; import { LoadingSpinner } from "#/components/modals/LoadingProject";
import { ModalBackdrop } from "#/components/modals/modal-backdrop"; import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { UserActions } from "#/components/user-actions"; import { UserActions } from "#/components/user-actions";
import { useSocket } from "#/context/socket";
import i18n from "#/i18n"; import i18n from "#/i18n";
import { getSettings, settingsAreUpToDate } from "#/services/settings"; import { getSettings, settingsAreUpToDate } from "#/services/settings";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react"; import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/assets/new-project.svg?react"; import NewProjectIcon from "#/icons/new-project.svg?react";
import DocsIcon from "#/assets/docs.svg?react"; import DocsIcon from "#/icons/docs.svg?react";
import { userIsAuthenticated } from "#/utils/user-is-authenticated"; import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url"; import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal"; import { WaitlistModal } from "#/components/waitlist-modal";
@@ -135,7 +134,6 @@ type SettingsFormData = {
}; };
export default function MainApp() { export default function MainApp() {
const { stop, isConnected } = useSocket();
const navigation = useNavigation(); const navigation = useNavigation();
const location = useLocation(); const location = useLocation();
const { const {
@@ -202,14 +200,6 @@ export default function MainApp() {
} }
}, [user]); }, [user]);
React.useEffect(() => {
if (location.pathname === "/") {
// If the user is on the home page, we should stop the socket connection.
// This is relevant when the user redirects here for whatever reason.
if (isConnected) stop();
}
}, [location.pathname]);
const handleUserLogout = () => { const handleUserLogout = () => {
logoutFetcher.submit( logoutFetcher.submit(
{}, {},
@@ -313,11 +303,9 @@ export default function MainApp() {
<p className="text-xs text-[#A3A3A3]"> <p className="text-xs text-[#A3A3A3]">
To continue, connect an OpenAI, Anthropic, or other LLM account To continue, connect an OpenAI, Anthropic, or other LLM account
</p> </p>
{isConnected && ( <p className="text-xs text-danger">
<p className="text-xs text-danger"> Changing settings during an active session will end the session
Changing settings during an active session will end the session </p>
</p>
)}
<SettingsForm <SettingsForm
settings={settings} settings={settings}
models={settingsFormData.models} models={settingsFormData.models}
+13 -18
View File
@@ -12,8 +12,11 @@ import {
import { setCurStatusMessage } from "#/state/statusSlice"; import { setCurStatusMessage } from "#/state/statusSlice";
import store from "#/store"; import store from "#/store";
import ActionType from "#/types/ActionType"; import ActionType from "#/types/ActionType";
import { ActionMessage, StatusMessage } from "#/types/Message"; import {
import { SocketMessage } from "#/types/ResponseType"; ActionMessage,
ObservationMessage,
StatusMessage,
} from "#/types/Message";
import { handleObservationMessage } from "./observations"; import { handleObservationMessage } from "./observations";
const messageActions = { const messageActions = {
@@ -138,22 +141,14 @@ export function handleStatusMessage(message: StatusMessage) {
} }
} }
export function handleAssistantMessage(data: string | SocketMessage) { export function handleAssistantMessage(message: Record<string, unknown>) {
let socketMessage: SocketMessage; if (message.action) {
handleActionMessage(message as unknown as ActionMessage);
if (typeof data === "string") { } else if (message.observation) {
socketMessage = JSON.parse(data) as SocketMessage; handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else { } else {
socketMessage = data; console.error("Unknown message type", message);
}
if ("action" in socketMessage) {
handleActionMessage(socketMessage);
} else if ("observation" in socketMessage) {
handleObservationMessage(socketMessage);
} else if ("status_update" in socketMessage) {
handleStatusMessage(socketMessage);
} else {
console.error("Unknown message type", socketMessage);
} }
} }
+4 -5
View File
@@ -1,8 +1,7 @@
import ActionType from "#/types/ActionType"; import ActionType from "#/types/ActionType";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
export const generateAgentStateChangeEvent = (state: AgentState) => export const generateAgentStateChangeEvent = (state: AgentState) => ({
JSON.stringify({ action: ActionType.CHANGE_AGENT_STATE,
action: ActionType.CHANGE_AGENT_STATE, args: { agent_state: state },
args: { agent_state: state }, });
});
+10
View File
@@ -63,6 +63,16 @@ export async function request(
} catch (e) { } catch (e) {
onFail(`Error fetching ${url}`); onFail(`Error fetching ${url}`);
} }
if (response?.status === 401 && !url.startsWith("/api/authenticate")) {
await request(
"/api/authenticate",
{
method: "POST",
},
true,
);
return request(url, options, disableToast, returnResponse, maxRetries - 1);
}
if (response?.status && response?.status >= 400) { if (response?.status && response?.status >= 400) {
onFail( onFail(
`${response.status} error while fetching ${url}: ${response?.statusText}`, `${response.status} error while fetching ${url}: ${response?.statusText}`,
+1 -1
View File
@@ -9,5 +9,5 @@ export function createChatMessage(
action: ActionType.MESSAGE, action: ActionType.MESSAGE,
args: { content: message, images_urls, timestamp }, args: { content: message, images_urls, timestamp },
}; };
return JSON.stringify(event); return event;
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import ActionType from "#/types/ActionType";
export function getTerminalCommand(command: string, hidden: boolean = false) { export function getTerminalCommand(command: string, hidden: boolean = false) {
const event = { action: ActionType.RUN, args: { command, hidden } }; const event = { action: ActionType.RUN, args: { command, hidden } };
return JSON.stringify(event); return event;
} }
export function getGitHubTokenCommand(gitHubToken: string) { export function getGitHubTokenCommand(gitHubToken: string) {
+15 -24
View File
@@ -5,26 +5,17 @@ type CacheEntry<T> = {
}; };
class Cache { class Cache {
private prefix = "app_cache_";
private defaultTTL = 5 * 60 * 1000; // 5 minutes private defaultTTL = 5 * 60 * 1000; // 5 minutes
/** private cacheMemory: Record<string, string> = {};
* Generate a unique key with prefix for local storage
* @param key The key to be stored in local storage
* @returns The unique key with prefix
*/
private getKey(key: CacheKey): string {
return `${this.prefix}${key}`;
}
/** /**
* Retrieve the cached data from local storage * Retrieve the cached data from memory
* @param key The key to be retrieved from local storage * @param key The key to be retrieved from memory
* @returns The data stored in local storage * @returns The data stored in memory
*/ */
public get<T>(key: CacheKey): T | null { public get<T>(key: CacheKey): T | null {
const cachedEntry = localStorage.getItem(this.getKey(key)); const cachedEntry = this.cacheMemory[key];
if (cachedEntry) { if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>; const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data; if (Date.now() < expiration) return data;
@@ -35,34 +26,34 @@ class Cache {
} }
/** /**
* Store the data in local storage with expiration * Store the data in memory with expiration
* @param key The key to be stored in local storage * @param key The key to be stored in memory
* @param data The data to be stored in local storage * @param data The data to be stored in memory
* @param ttl The time to live for the data in milliseconds * @param ttl The time to live for the data in milliseconds
* @returns void * @returns void
*/ */
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void { public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
const expiration = Date.now() + ttl; const expiration = Date.now() + ttl;
const entry: CacheEntry<T> = { data, expiration }; const entry: CacheEntry<T> = { data, expiration };
localStorage.setItem(this.getKey(key), JSON.stringify(entry)); this.cacheMemory[key] = JSON.stringify(entry);
} }
/** /**
* Remove the data from local storage * Remove the data from memory
* @param key The key to be removed from local storage * @param key The key to be removed from memory
* @returns void * @returns void
*/ */
public delete(key: CacheKey): void { public delete(key: CacheKey): void {
localStorage.removeItem(this.getKey(key)); delete this.cacheMemory[key];
} }
/** /**
* Clear all data with the app prefix from local storage * Clear all data
* @returns void * @returns void
*/ */
public clearAll(): void { public clearAll(): void {
Object.keys(localStorage).forEach((key) => { Object.keys(this.cacheMemory).forEach((key) => {
if (key.startsWith(this.prefix)) localStorage.removeItem(key); delete this.cacheMemory[key];
}); });
} }
} }
@@ -26,6 +26,7 @@ import { extractModelAndProvider } from "./extractModelAndProvider";
*/ */
export const organizeModelsAndProviders = (models: string[]) => { export const organizeModelsAndProviders = (models: string[]) => {
const object: Record<string, { separator: string; models: string[] }> = {}; const object: Record<string, { separator: string; models: string[] }> = {};
models.forEach((model) => { models.forEach((model) => {
const { const {
separator, separator,
@@ -45,5 +46,6 @@ export const organizeModelsAndProviders = (models: string[]) => {
} }
object[key].models.push(modelId); object[key].models.push(modelId);
}); });
return object; return object;
}; };
+2 -8
View File
@@ -1,10 +1,6 @@
// Here are the list of verified models and providers that we know work well with OpenHands. // Here are the list of verified models and providers that we know work well with OpenHands.
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"]; export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"];
export const VERIFIED_MODELS = [ export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"];
"gpt-4o",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency // LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`) // (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
@@ -23,11 +19,9 @@ export const VERIFIED_OPENAI_MODELS = [
export const VERIFIED_ANTHROPIC_MODELS = [ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2", "claude-2",
"claude-2.1", "claude-2.1",
"claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307", "claude-3-haiku-20240307",
"claude-3-opus-20240229", "claude-3-opus-20240229",
"claude-3-sonnet-20240229", "claude-3-sonnet-20240229",
"claude-instant-1",
"claude-instant-1.2",
]; ];
+2 -2
View File
@@ -6,7 +6,7 @@ import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react"; import { RenderOptions, render } from "@testing-library/react";
import { AppStore, RootState, rootReducer } from "./src/store"; import { AppStore, RootState, rootReducer } from "./src/store";
import { SocketProvider } from "#/context/socket"; import { WsClientProvider } from "#/context/ws-client-provider";
const setupStore = (preloadedState?: Partial<RootState>): AppStore => const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({ configureStore({
@@ -35,7 +35,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element { function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
return ( return (
<Provider store={store}> <Provider store={store}>
<SocketProvider>{children}</SocketProvider> <WsClientProvider enabled={true} token={null} ghToken={null} settings={null}>{children}</WsClientProvider>
</Provider> </Provider>
); );
} }
@@ -39,7 +39,6 @@ from openhands.runtime.plugins import (
JupyterRequirement, JupyterRequirement,
PluginRequirement, PluginRequirement,
) )
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager from openhands.utils.prompt import PromptManager
@@ -86,16 +85,6 @@ class CodeActAgent(Agent):
super().__init__(llm, config) super().__init__(llm, config)
self.reset() self.reset()
self.micro_agent = (
MicroAgent(
os.path.join(
os.path.dirname(__file__), 'micro', f'{config.micro_agent_name}.md'
)
)
if config.micro_agent_name
else None
)
self.function_calling_active = self.config.function_calling self.function_calling_active = self.config.function_calling
if self.function_calling_active and not self.llm.is_function_calling_active(): if self.function_calling_active and not self.llm.is_function_calling_active():
logger.warning( logger.warning(
@@ -105,7 +94,6 @@ class CodeActAgent(Agent):
self.function_calling_active = False self.function_calling_active = False
if self.function_calling_active: if self.function_calling_active:
# Function calling mode
self.tools = codeact_function_calling.get_tools( self.tools = codeact_function_calling.get_tools(
codeact_enable_browsing=self.config.codeact_enable_browsing, codeact_enable_browsing=self.config.codeact_enable_browsing,
codeact_enable_jupyter=self.config.codeact_enable_jupyter, codeact_enable_jupyter=self.config.codeact_enable_jupyter,
@@ -114,18 +102,17 @@ class CodeActAgent(Agent):
logger.debug( logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}' f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
) )
self.system_prompt = codeact_function_calling.SYSTEM_PROMPT self.prompt_manager = PromptManager(
self.initial_user_message = None microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
)
else: else:
# Non-function-calling mode
self.action_parser = CodeActResponseParser() self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager( self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__)), microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
agent_skills_docs=AgentSkillsRequirement.documentation, agent_skills_docs=AgentSkillsRequirement.documentation,
micro_agent=self.micro_agent,
) )
self.system_prompt = self.prompt_manager.system_message
self.initial_user_message = self.prompt_manager.initial_user_message
self.pending_actions: deque[Action] = deque() self.pending_actions: deque[Action] = deque()
@@ -337,8 +324,8 @@ class CodeActAgent(Agent):
return self.pending_actions.popleft() return self.pending_actions.popleft()
# if we're done, go back # if we're done, go back
last_user_message = state.get_last_user_message() latest_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit': if latest_user_message and latest_user_message.content.strip() == '/exit':
return AgentFinishAction() return AgentFinishAction()
# prepare what we want to send to the LLM # prepare what we want to send to the LLM
@@ -403,17 +390,19 @@ class CodeActAgent(Agent):
role='system', role='system',
content=[ content=[
TextContent( TextContent(
text=self.system_prompt, text=self.prompt_manager.get_system_message(),
cache_prompt=self.llm.is_caching_prompt_active(), # Cache system prompt cache_prompt=self.llm.is_caching_prompt_active(),
) )
], ],
) )
] ]
if self.initial_user_message: example_message = self.prompt_manager.get_example_user_message()
if example_message:
messages.append( messages.append(
Message( Message(
role='user', role='user',
content=[TextContent(text=self.initial_user_message)], content=[TextContent(text=example_message)],
cache_prompt=self.llm.is_caching_prompt_active(),
) )
) )
@@ -462,8 +451,9 @@ class CodeActAgent(Agent):
pending_tool_call_action_messages.pop(response_id) pending_tool_call_action_messages.pop(response_id)
for message in messages_to_add: for message in messages_to_add:
# add regular message
if message: if message:
if message.role == 'user':
self.prompt_manager.enhance_message(message)
# handle error if the message is the SAME role as the previous message # handle error if the message is the SAME role as the previous message
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'} # litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
# there shouldn't be two consecutive messages from the same role # there shouldn't be two consecutive messages from the same role
@@ -493,23 +483,6 @@ class CodeActAgent(Agent):
break break
if not self.function_calling_active: if not self.function_calling_active:
# The latest user message is important: self.prompt_manager.add_turns_left_reminder(messages, state)
# we want to remind the agent of the environment constraints
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
)
# do not add this for function calling
if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
return messages return messages
@@ -25,14 +25,6 @@ from openhands.events.action import (
) )
from openhands.events.tool import ToolCallMetadata from openhands.events.tool import ToolCallMetadata
SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>
"""
_BASH_DESCRIPTION = """Execute a bash command in the terminal. _BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`. * Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process. * Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.
@@ -0,0 +1,9 @@
---
name: flarglebargle
agent: CodeActAgent
triggers:
- flarglebargle
---
IMPORTANT! The user has said the magic word "flarglebargle". You must
only respond with a message telling them how smart they are
@@ -1,69 +1,31 @@
--- ---
name: github name: github
agent: CodeActAgent agent: CodeActAgent
require_env_var: triggers:
SANDBOX_ENV_GITHUB_TOKEN: "Create a GitHub Personal Access Token (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and set it as SANDBOX_GITHUB_TOKEN in your environment variables." - github
- git
--- ---
# How to Interact with Github You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
the GitHub API.
## Environment Variable Available You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
ALWAYS use the GitHub API for operations instead of a web browser.
- `GITHUB_TOKEN`: A read-only token for Github. 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
## Using GitHub's RESTful API * Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch called `openhands-workspace`. Create a new branch with a better name before pushing.
Use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API. Here are some common operations: * Use the GitHub API to create a pull request, if you haven't already
* Use the main branch as the base branch, unless the user requests otherwise
Here's a template for API calls: * After opening or updating a pull request, send the user a short message with a link to the pull request.
* 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:
```sh ```bash
curl -H "Authorization: token $GITHUB_TOKEN" \ git checkout -b create-widget
"https://api.github.com/{endpoint}" git add .
git commit -m "Create widget"
git push origin create-widget
curl -X POST "https://api.github.com/repos/CodeActOrg/openhands/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
``` ```
First replace `{endpoint}` with the specific API path. Common operations:
1. View an issue or pull request:
- Issues: `/repos/{owner}/{repo}/issues/{issue_number}`
- Pull requests: `/repos/{owner}/{repo}/pulls/{pull_request_number}`
2. List repository issues or pull requests:
- Issues: `/repos/{owner}/{repo}/issues`
- Pull requests: `/repos/{owner}/{repo}/pulls`
3. Search issues or pull requests:
- `/search/issues?q=repo:{owner}/{repo}+is:{type}+{search_term}+state:{state}`
- Replace `{type}` with `issue` or `pr`
4. List repository branches:
`/repos/{owner}/{repo}/branches`
5. Get commit details:
`/repos/{owner}/{repo}/commits/{commit_sha}`
6. Get repository details:
`/repos/{owner}/{repo}`
7. Get user information:
`/user`
8. Search repositories:
`/search/repositories?q={query}`
9. Get rate limit status:
`/rate_limit`
Replace `{owner}`, `{repo}`, `{commit_sha}`, `{issue_number}`, `{pull_request_number}`,
`{search_term}`, `{state}`, and `{query}` with appropriate values.
## Important Notes
1. Always use the GitHub API for operations instead of a web browser.
2. The `GITHUB_TOKEN` is read-only. Avoid operations that require write access.
3. Git config (username and email) is pre-set. Do not modify.
4. Edit and test code locally. Never push directly to remote.
5. Verify correct branch before committing.
6. Commit changes frequently.
7. If the issue or task is ambiguous or lacks sufficient detail, always request clarification from the user before proceeding.
8. You should avoid using command line tools like `sed` for file editing.
@@ -215,12 +215,5 @@ The server is running on port 5000 with PID 126. You can access the list of numb
{% endset %} {% endset %}
Here is an example of how you can interact with the environment for task solving: Here is an example of how you can interact with the environment for task solving:
{{ DEFAULT_EXAMPLE }} {{ DEFAULT_EXAMPLE }}
{% if micro_agent %}
--- BEGIN OF GUIDELINE ---
The following information may assist you in completing your task:
{{ micro_agent }}
--- END OF GUIDELINE ---
{% endif %}
NOW, LET'S START! NOW, LET'S START!
@@ -0,0 +1,7 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>
@@ -155,7 +155,7 @@ class CodeActSWEAgent(Agent):
""" """
# if we're done, go back # if we're done, go back
last_user_message = state.get_last_user_message() last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit': if last_user_message and last_user_message.content.strip() == '/exit':
return AgentFinishAction() return AgentFinishAction()
# prepare what we want to send to the LLM # prepare what we want to send to the LLM
+16 -5
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import copy import copy
import os
import traceback import traceback
from typing import Callable, ClassVar, Type from typing import Callable, ClassVar, Type
@@ -259,7 +260,11 @@ class AgentController:
observation_to_print.content = truncate_content( observation_to_print.content = truncate_content(
observation_to_print.content, self.agent.llm.config.max_message_chars observation_to_print.content, self.agent.llm.config.max_message_chars
) )
self.log('debug', str(observation_to_print), extra={'msg_type': 'OBSERVATION'}) # Use info level if LOG_ALL_EVENTS is set
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
self.log(
log_level, str(observation_to_print), extra={'msg_type': 'OBSERVATION'}
)
if observation.llm_metrics is not None: if observation.llm_metrics is not None:
self.agent.llm.metrics.merge(observation.llm_metrics) self.agent.llm.metrics.merge(observation.llm_metrics)
@@ -282,8 +287,12 @@ class AgentController:
action (MessageAction): The message action to handle. action (MessageAction): The message action to handle.
""" """
if action.source == EventSource.USER: if action.source == EventSource.USER:
# Use info level if LOG_ALL_EVENTS is set
log_level = (
'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
)
self.log( self.log(
'debug', log_level,
str(action), str(action),
extra={'msg_type': 'ACTION', 'event_source': EventSource.USER}, extra={'msg_type': 'ACTION', 'event_source': EventSource.USER},
) )
@@ -497,7 +506,9 @@ class AgentController:
await self.update_state_after_step() await self.update_state_after_step()
self.log('debug', str(action), extra={'msg_type': 'ACTION'}) # Use info level if LOG_ALL_EVENTS is set
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
async def _delegate_step(self): async def _delegate_step(self):
"""Executes a single step of the delegate agent.""" """Executes a single step of the delegate agent."""
@@ -663,7 +674,7 @@ class AgentController:
# sanity check # sanity check
if start_id > end_id + 1: if start_id > end_id + 1:
self.log( self.log(
'debug', 'warning',
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.', f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
) )
self.state.history = [] self.state.history = []
@@ -694,7 +705,7 @@ class AgentController:
# Match with most recent unmatched delegate action # Match with most recent unmatched delegate action
if not delegate_action_ids: if not delegate_action_ids:
self.log( self.log(
'error', 'warning',
f'Found AgentDelegateObservation without matching action at id={event.id}', f'Found AgentDelegateObservation without matching action at id={event.id}',
) )
continue continue
+4 -4
View File
@@ -156,14 +156,14 @@ class State:
return last_user_message, last_user_message_image_urls return last_user_message, last_user_message_image_urls
def get_last_agent_message(self) -> str | None: def get_last_agent_message(self) -> MessageAction | None:
for event in reversed(self.history): for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.AGENT: if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
return event.content return event
return None return None
def get_last_user_message(self) -> str | None: def get_last_user_message(self) -> MessageAction | None:
for event in reversed(self.history): for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER: if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event.content return event
return None return None
+2
View File
@@ -69,6 +69,7 @@ class AppConfig:
file_uploads_max_file_size_mb: int = 0 file_uploads_max_file_size_mb: int = 0
file_uploads_restrict_file_types: bool = False file_uploads_restrict_file_types: bool = False
file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*']) file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
runloop_api_key: str | None = None
defaults_dict: ClassVar[dict] = {} defaults_dict: ClassVar[dict] = {}
@@ -139,6 +140,7 @@ class AppConfig:
'jwt_secret', 'jwt_secret',
'modal_api_token_id', 'modal_api_token_id',
'modal_api_token_secret', 'modal_api_token_secret',
'runloop_api_key',
]: ]:
attr_value = '******' if attr_value else None attr_value = '******' if attr_value else None
+3 -1
View File
@@ -14,7 +14,8 @@ class SandboxConfig:
base_container_image: The base container image from which to build the runtime image. base_container_image: The base container image from which to build the runtime image.
runtime_container_image: The runtime container image to use. runtime_container_image: The runtime container image to use.
user_id: The user ID for the sandbox. user_id: The user ID for the sandbox.
timeout: The timeout for the sandbox. timeout: The timeout for the default sandbox action execution.
remote_runtime_init_timeout: The timeout for the remote runtime to start.
enable_auto_lint: Whether to enable auto-lint. enable_auto_lint: Whether to enable auto-lint.
use_host_network: Whether to use the host network. use_host_network: Whether to use the host network.
initialize_plugins: Whether to initialize plugins. initialize_plugins: Whether to initialize plugins.
@@ -41,6 +42,7 @@ class SandboxConfig:
runtime_container_image: str | None = None runtime_container_image: str | None = None
user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000 user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
timeout: int = 120 timeout: int = 120
remote_runtime_init_timeout: int = 180
enable_auto_lint: bool = ( enable_auto_lint: bool = (
False # once enabled, OpenHands would lint files after editing False # once enabled, OpenHands would lint files after editing
) )
+2 -2
View File
@@ -177,7 +177,7 @@ class SensitiveDataFilter(logging.Filter):
return True return True
def get_console_handler(log_level=logging.INFO, extra_info: str | None = None): def get_console_handler(log_level: int = logging.INFO, extra_info: str | None = None):
"""Returns a console handler for logging.""" """Returns a console handler for logging."""
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setLevel(log_level) console_handler.setLevel(log_level)
@@ -188,7 +188,7 @@ def get_console_handler(log_level=logging.INFO, extra_info: str | None = None):
return console_handler return console_handler
def get_file_handler(log_dir, log_level=logging.INFO): def get_file_handler(log_dir: str, log_level: int = logging.INFO):
"""Returns a file handler for logging.""" """Returns a file handler for logging."""
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y-%m-%d') timestamp = datetime.now().strftime('%Y-%m-%d')
+3 -3
View File
@@ -64,7 +64,7 @@ class BrowserOutputObservation(Observation):
# We do not filter visible only here because we want to show the full content # We do not filter visible only here because we want to show the full content
# of the web page to the agent for simplicity. # of the web page to the agent for simplicity.
# FIXME: handle the case when the web page is too large # FIXME: handle the case when the web page is too large
cur_axtree_txt = self.get_axtree_str(filter_visible_only=True) cur_axtree_txt = self.get_axtree_str(filter_visible_only=False)
text += ( text += (
f'============== BEGIN accessibility tree ==============\n' f'============== BEGIN accessibility tree ==============\n'
f'{cur_axtree_txt}\n' f'{cur_axtree_txt}\n'
@@ -74,12 +74,12 @@ class BrowserOutputObservation(Observation):
text += f'\n[Error encountered when processing the accessibility tree: {e}]' text += f'\n[Error encountered when processing the accessibility tree: {e}]'
return text return text
def get_axtree_str(self, filter_visible_only: bool = True) -> str: def get_axtree_str(self, filter_visible_only: bool = False) -> str:
cur_axtree_txt = flatten_axtree_to_str( cur_axtree_txt = flatten_axtree_to_str(
self.axtree_object, self.axtree_object,
extra_properties=self.extra_element_properties, extra_properties=self.extra_element_properties,
with_clickable=True, with_clickable=True,
skip_generic=True, skip_generic=False,
filter_visible_only=filter_visible_only, filter_visible_only=filter_visible_only,
) )
self._axtree_str = cur_axtree_txt self._axtree_str = cur_axtree_txt
+4
View File
@@ -23,6 +23,10 @@ def get_runtime_cls(name: str):
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
return ModalRuntime return ModalRuntime
elif name == 'runloop':
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
return RunloopRuntime
else: else:
raise ValueError(f'Runtime {name} not supported') raise ValueError(f'Runtime {name} not supported')
@@ -7,7 +7,9 @@ NOTE: this will be executed inside the docker sandbox.
import argparse import argparse
import asyncio import asyncio
import base64
import io import io
import mimetypes
import os import os
import shutil import shutil
import tempfile import tempfile
@@ -217,6 +219,33 @@ class ActionExecutor:
working_dir = self.bash_session.workdir working_dir = self.bash_session.workdir
filepath = self._resolve_path(action.path, working_dir) filepath = self._resolve_path(action.path, working_dir)
try: try:
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
with open(filepath, 'rb') as file:
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'image/png' # default to PNG if mime type cannot be determined
encoded_image = f'data:{mime_type};base64,{encoded_image}'
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file:
pdf_data = file.read()
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
return FileReadObservation(path=filepath, content=encoded_pdf)
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
with open(filepath, 'rb') as file:
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'video/mp4' # default to MP4 if MIME type cannot be determined
encoded_video = f'data:{mime_type};base64,{encoded_video}'
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file: with open(filepath, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), action.start, action.end) lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError: except FileNotFoundError:
+16 -10
View File
@@ -1,7 +1,7 @@
import os import os
from pathlib import Path
import tempfile import tempfile
import threading import threading
from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from zipfile import ZipFile from zipfile import ZipFile
@@ -137,7 +137,7 @@ class RemoteRuntime(Runtime):
try: try:
response = self._send_request( response = self._send_request(
'GET', 'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.sid}', f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
timeout=5, timeout=5,
) )
except requests.HTTPError as e: except requests.HTTPError as e:
@@ -227,7 +227,7 @@ class RemoteRuntime(Runtime):
'command': command, 'command': command,
'working_dir': '/openhands/code/', 'working_dir': '/openhands/code/',
'environment': {'DEBUG': 'true'} if self.config.debug else {}, 'environment': {'DEBUG': 'true'} if self.config.debug else {},
'runtime_id': self.sid, 'session_id': self.sid,
} }
# Start the sandbox using the /start endpoint # Start the sandbox using the /start endpoint
@@ -260,17 +260,23 @@ class RemoteRuntime(Runtime):
{'X-Session-API-Key': start_response['session_api_key']} {'X-Session-API-Key': start_response['session_api_key']}
) )
@tenacity.retry(
stop=tenacity.stop_after_delay(180) | stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
def _wait_until_alive(self): def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
self.config.sandbox.remote_runtime_init_timeout
)
| stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
return retry_decorator(self._wait_until_alive_impl)()
def _wait_until_alive_impl(self):
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}') self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
runtime_info_response = self._send_request( runtime_info_response = self._send_request(
'GET', 'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}', f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
) )
runtime_data = runtime_info_response.json() runtime_data = runtime_info_response.json()
assert 'runtime_id' in runtime_data assert 'runtime_id' in runtime_data
+31
View File
@@ -0,0 +1,31 @@
# Runloop Runtime
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
for more detail
## Access
Runloop is currently available in a closed beta. For early access, or
just to say hello, sign up at https://www.runloop.ai/hello
## Set up
With your runloop API,
```bash
export RUNLOOP_API_KEY=<your-api-key>
```
Configure the runtime
```bash
export RUNTIME="runloop"
```
## Interact with your devbox
Runloop provides additional tools to interact with your Devbox based
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
to date list of tools.
### Dashboard
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
### CLI
Use the Runloop CLI to view logs, execute commands, and more.
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
@@ -0,0 +1,272 @@
import logging
import threading
import time
from typing import Callable
import requests
import tenacity
from runloop_api_client import Runloop
from runloop_api_client.types import DevboxView
from runloop_api_client.types.shared_params import LaunchParameters
from openhands.core.config import AppConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.runtime.impl.eventstream.eventstream_runtime import (
EventStreamRuntime,
LogBuffer,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.request import send_request
from openhands.utils.tenacity_stop import stop_if_should_exit
class RunloopLogBuffer(LogBuffer):
"""Synchronous buffer for Runloop devbox logs.
This class provides a thread-safe way to collect, store, and retrieve logs
from a Docker container. It uses a list to store log lines and provides methods
for appending, retrieving, and clearing logs.
"""
def __init__(self, runloop_api_client: Runloop, devbox_id: str):
self.client_ready = False
self.init_msg = 'Runtime client initialized.'
self.buffer: list[str] = []
self.lock = threading.Lock()
self._stop_event = threading.Event()
self.runloop_api_client = runloop_api_client
self.devbox_id = devbox_id
self.log_index = 0
self.log_stream_thread = threading.Thread(target=self.stream_logs)
self.log_stream_thread.daemon = True
self.log_stream_thread.start()
def stream_logs(self):
"""Stream logs from the Docker container in a separate thread.
This method runs in its own thread to handle the blocking
operation of reading log lines from the Docker SDK's synchronous generator.
"""
try:
# TODO(Runloop) Replace with stream
while True:
raw_logs = self.runloop_api_client.devboxes.logs.list(
self.devbox_id
).logs[self.log_index :]
logs = [
log.message
for log in raw_logs
if log.message and log.cmd_id is None
]
self.log_index += len(raw_logs)
if self._stop_event.is_set():
break
if logs:
for log_line in logs:
self.append(log_line)
if self.init_msg in log_line:
self.client_ready = True
time.sleep(1)
except Exception as e:
logger.error(f'Error streaming runloop logs: {e}')
# NB: Match LogBuffer behavior on below methods
def get_and_clear(self) -> list[str]:
with self.lock:
logs = list(self.buffer)
self.buffer.clear()
return logs
def append(self, log_line: str):
with self.lock:
self.buffer.append(log_line)
def close(self, timeout: float = 5.0):
self._stop_event.set()
self.log_stream_thread.join(timeout)
class RunloopRuntime(EventStreamRuntime):
"""The RunloopRuntime class is an EventStreamRuntime that utilizes Runloop Devbox as a runtime environment."""
_sandbox_port: int = 4444
def __init__(
self,
config: AppConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
):
assert config.runloop_api_key is not None, 'Runloop API key is required'
self.devbox: DevboxView | None = None
self.config = config
self.runloop_api_client = Runloop(
bearer_token=config.runloop_api_key,
)
self.session = requests.Session()
self.container_name = self.container_name_prefix + sid
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self.init_base_runtime(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
)
# Buffer for container logs
self.log_buffer: LogBuffer | None = None
@tenacity.retry(
stop=tenacity.stop_after_attempt(120),
wait=tenacity.wait_fixed(1),
)
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
"""Pull devbox status until it is running"""
if devbox == 'running':
return devbox
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
if devbox.status != 'running':
raise ConnectionRefusedError('Devbox is not running')
# Devbox is connected and running
logging.debug(f'devbox.id={devbox.id} is running')
return devbox
def _create_new_devbox(self) -> DevboxView:
# Note: Runloop connect
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
plugin_args = []
if self.plugins is not None and len(self.plugins) > 0:
plugin_args.append('--plugins')
plugin_args.extend([plugin.name for plugin in self.plugins])
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'-browsergym-eval-env',
self.config.sandbox.browsergym_eval_env,
]
# Copied from EventstreamRuntime
start_command = get_remote_startup_command(
self._sandbox_port,
sandbox_workspace_dir,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
)
# Add some additional commands based on our image
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
# (ie browser) to be installed as root
start_command = (
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
'cd /openhands/code && '
+ '/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
+ ' '.join(start_command)
)
entrypoint = f"sudo bash -c '{start_command}'"
devbox = self.runloop_api_client.devboxes.create(
entrypoint=entrypoint,
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
name=self.sid,
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port],
resource_size_request="LARGE",
),
metadata={'container-name': self.container_name},
)
return self._wait_for_devbox(devbox)
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
if self.attach_to_existing:
active_devboxes = self.runloop_api_client.devboxes.list(
status='running'
).devboxes
self.devbox = next(
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
)
if self.devbox is None:
self.devbox = self._create_new_devbox()
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
tunnel = self.runloop_api_client.devboxes.create_tunnel(
id=self.devbox.id,
port=self._sandbox_port,
)
# Hook up logs
self.log_buffer = RunloopLogBuffer(self.runloop_api_client, self.devbox.id)
self.api_url = f'https://{tunnel.url}'
logger.info(f'Container started. Server url: {self.api_url}')
# End Runloop connect
# NOTE: Copied from EventStreamRuntime
logger.info('Waiting for client to become ready...')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
self._wait_until_alive()
if not self.attach_to_existing:
self.setup_initial_env()
logger.info(
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
)
self.send_status_message(' ')
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=(ConnectionRefusedError,),
)
def _wait_until_alive(self):
# NB(Runloop): Remote logs are not guaranteed realtime, removing client_ready check from logs
self._refresh_logs()
if not self.log_buffer:
raise RuntimeError('Runtime client is not ready.')
response = send_request(
self.session,
'GET',
f'{self.api_url}/alive',
timeout=5,
)
if response.status_code == 200:
return
else:
msg = f'Action execution API is not alive. Response: {response}'
logger.error(msg)
raise RuntimeError(msg)
def close(self, rm_all_containers: bool = True):
if self.log_buffer:
self.log_buffer.close()
if self.session:
self.session.close()
if self.attach_to_existing:
return
if self.devbox:
self.runloop_api_client.devboxes.shutdown(self.devbox.id)
+22 -31
View File
@@ -1,10 +1,12 @@
import os import os
import httpx from github import Github
from github.GithubException import GithubException
from tenacity import retry, stop_after_attempt, wait_exponential from tenacity import retry, stop_after_attempt, wait_exponential
from openhands.core.logger import openhands_logger as logger from openhands.core.logger import openhands_logger as logger
from openhands.server.sheets_client import GoogleSheetsClient from openhands.server.sheets_client import GoogleSheetsClient
from openhands.utils.async_utils import call_sync_from_async
GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '').strip() GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '').strip()
GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip() GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
@@ -12,7 +14,7 @@ GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
class UserVerifier: class UserVerifier:
def __init__(self) -> None: def __init__(self) -> None:
logger.info('Initializing UserVerifier') logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None self.spreadsheet_id: str | None = None
@@ -25,7 +27,7 @@ class UserVerifier:
"""Load users from text file if configured""" """Load users from text file if configured"""
waitlist = os.getenv('GITHUB_USER_LIST_FILE') waitlist = os.getenv('GITHUB_USER_LIST_FILE')
if not waitlist: if not waitlist:
logger.info('GITHUB_USER_LIST_FILE not configured') logger.debug('GITHUB_USER_LIST_FILE not configured')
return return
if not os.path.exists(waitlist): if not os.path.exists(waitlist):
@@ -46,10 +48,10 @@ class UserVerifier:
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID') sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id: if not sheet_id:
logger.info('GITHUB_USERS_SHEET_ID not configured') logger.debug('GITHUB_USERS_SHEET_ID not configured')
return return
logger.info('Initializing Google Sheets integration') logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient() self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id self.spreadsheet_id = sheet_id
@@ -61,21 +63,21 @@ class UserVerifier:
if not self.is_active(): if not self.is_active():
return True return True
logger.info(f'Checking if GitHub user {username} is allowed') logger.debug(f'Checking if GitHub user {username} is allowed')
if self.file_users: if self.file_users:
if username in self.file_users: if username in self.file_users:
logger.info(f'User {username} found in text file allowlist') logger.debug(f'User {username} found in text file allowlist')
return True return True
logger.debug(f'User {username} not found in text file allowlist') logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id: if self.sheets_client and self.spreadsheet_id:
sheet_users = self.sheets_client.get_usernames(self.spreadsheet_id) sheet_users = self.sheets_client.get_usernames(self.spreadsheet_id)
if username in sheet_users: if username in sheet_users:
logger.info(f'User {username} found in Google Sheets allowlist') logger.debug(f'User {username} found in Google Sheets allowlist')
return True return True
logger.debug(f'User {username} not found in Google Sheets allowlist') logger.debug(f'User {username} not found in Google Sheets allowlist')
logger.info(f'User {username} not found in any allowlist') logger.debug(f'User {username} not found in any allowlist')
return False return False
@@ -83,10 +85,10 @@ async def authenticate_github_user(auth_token) -> bool:
user_verifier = UserVerifier() user_verifier = UserVerifier()
if not user_verifier.is_active(): if not user_verifier.is_active():
logger.info('No user verification sources configured - allowing all users') logger.debug('No user verification sources configured - allowing all users')
return True return True
logger.info('Checking GitHub token') logger.debug('Checking GitHub token')
if not auth_token: if not auth_token:
logger.warning('No GitHub token provided') logger.warning('No GitHub token provided')
@@ -112,25 +114,14 @@ async def get_github_user(token: str) -> str:
Returns: Returns:
github handle of the user github handle of the user
""" """
logger.info('Fetching GitHub user info from token') logger.debug('Fetching GitHub user info from token')
headers = { try:
'Accept': 'application/vnd.github+json', g = Github(token)
'Authorization': f'Bearer {token}', user = await call_sync_from_async(g.get_user)
} login = user.login
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0)
) as client:
try:
response = await client.get('https://api.github.com/user', headers=headers)
except httpx.RequestError as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
logger.info('Received response from GitHub API')
logger.debug(f'Response status code: {response.status_code}')
response.raise_for_status()
user_data = response.json()
login = user_data.get('login')
logger.info(f'Successfully retrieved GitHub user: {login}') logger.info(f'Successfully retrieved GitHub user: {login}')
return login return login
except GithubException as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
+36 -7
View File
@@ -2,10 +2,12 @@ import asyncio
import os import os
import re import re
import tempfile import tempfile
import time
import uuid import uuid
import warnings import warnings
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import jwt
import requests import requests
from pathspec import PathSpec from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern from pathspec.patterns import GitWildMatchPattern
@@ -15,6 +17,7 @@ from openhands.server.data_models.feedback import FeedbackDataModel, store_feedb
from openhands.server.github import ( from openhands.server.github import (
GITHUB_CLIENT_ID, GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET, GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user, authenticate_github_user,
) )
from openhands.storage import get_file_store from openhands.storage import get_file_store
@@ -60,7 +63,7 @@ from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper from openhands.events.stream import AsyncEventStreamWrapper
from openhands.llm import bedrock from openhands.llm import bedrock
from openhands.runtime.base import Runtime from openhands.runtime.base import Runtime
from openhands.server.auth import get_sid_from_token, sign_token from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager from openhands.server.session import SessionManager
@@ -204,12 +207,22 @@ async def attach_session(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
return response return response
github_token = request.headers.get('X-GitHub-Token') user_verifier = UserVerifier()
if not await authenticate_github_user(github_token): if user_verifier.is_active():
return JSONResponse( signed_token = request.cookies.get('github_auth')
status_code=status.HTTP_401_UNAUTHORIZED, if not signed_token:
content={'error': 'Not authenticated'}, return JSONResponse(
) status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authenticated'},
)
try:
jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
if not request.headers.get('Authorization'): if not request.headers.get('Authorization'):
logger.warning('Missing Authorization header') logger.warning('Missing Authorization header')
@@ -864,10 +877,26 @@ async def authenticate(request: Request):
content={'error': 'Not authorized via GitHub waitlist'}, content={'error': 'Not authorized via GitHub waitlist'},
) )
# Create a signed JWT token with 1-hour expiration
cookie_data = {
'github_token': token,
'exp': int(time.time()) + 3600, # 1 hour expiration
}
signed_token = sign_token(cookie_data, config.jwt_secret)
response = JSONResponse( response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'} status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
) )
# Set secure cookie with signed token
response.set_cookie(
key='github_auth',
value=signed_token,
max_age=3600, # 1 hour in seconds
httponly=True,
secure=True,
samesite='strict',
)
return response return response
+23 -19
View File
@@ -3,15 +3,11 @@ import os
import frontmatter import frontmatter
import pydantic import pydantic
from openhands.controller.agent import Agent
from openhands.core.exceptions import MicroAgentValidationError
from openhands.core.logger import openhands_logger as logger
class MicroAgentMetadata(pydantic.BaseModel): class MicroAgentMetadata(pydantic.BaseModel):
name: str name: str
agent: str agent: str
require_env_var: dict[str, str] triggers: list[str] = []
class MicroAgent: class MicroAgent:
@@ -23,22 +19,30 @@ class MicroAgent:
self._loaded = frontmatter.load(file) self._loaded = frontmatter.load(file)
self._content = self._loaded.content self._content = self._loaded.content
self._metadata = MicroAgentMetadata(**self._loaded.metadata) self._metadata = MicroAgentMetadata(**self._loaded.metadata)
self._validate_micro_agent()
def get_trigger(self, message: str) -> str | None:
message = message.lower()
for trigger in self.triggers:
if trigger.lower() in message:
return trigger
return None
@property @property
def content(self) -> str: def content(self) -> str:
return self._content return self._content
def _validate_micro_agent(self): @property
logger.debug( def metadata(self) -> MicroAgentMetadata:
f'Loading and validating micro agent [{self._metadata.name}] based on [{self._metadata.agent}]' return self._metadata
)
# Make sure the agent is registered @property
agent_cls = Agent.get_cls(self._metadata.agent) def name(self) -> str:
assert agent_cls is not None return self._metadata.name
# Make sure the environment variables are set
for env_var, instruction in self._metadata.require_env_var.items(): @property
if env_var not in os.environ: def triggers(self) -> list[str]:
raise MicroAgentValidationError( return self._metadata.triggers
f'Environment variable [{env_var}] is required by micro agent [{self._metadata.name}] but not set. {instruction}'
) @property
def agent(self) -> str:
return self._metadata.agent
+54 -11
View File
@@ -1,7 +1,10 @@
import os import os
from itertools import islice
from jinja2 import Template from jinja2 import Template
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent from openhands.utils.microagent import MicroAgent
@@ -16,21 +19,31 @@ class PromptManager:
Attributes: Attributes:
prompt_dir (str): Directory containing prompt templates. prompt_dir (str): Directory containing prompt templates.
agent_skills_docs (str): Documentation of agent skills. agent_skills_docs (str): Documentation of agent skills.
micro_agent (MicroAgent | None): Micro-agent, if specified.
""" """
def __init__( def __init__(
self, self,
prompt_dir: str, prompt_dir: str,
agent_skills_docs: str, microagent_dir: str = '',
micro_agent: MicroAgent | None = None, agent_skills_docs: str = '',
): ):
self.prompt_dir: str = prompt_dir self.prompt_dir: str = prompt_dir
self.agent_skills_docs: str = agent_skills_docs self.agent_skills_docs: str = agent_skills_docs
self.system_template: Template = self._load_template('system_prompt') self.system_template: Template = self._load_template('system_prompt')
self.user_template: Template = self._load_template('user_prompt') self.user_template: Template = self._load_template('user_prompt')
self.micro_agent: MicroAgent | None = micro_agent self.microagents: dict = {}
microagent_files = []
if microagent_dir:
microagent_files = [
os.path.join(microagent_dir, f)
for f in os.listdir(microagent_dir)
if f.endswith('.md')
]
for microagent_file in microagent_files:
microagent = MicroAgent(microagent_file)
self.microagents[microagent.name] = microagent
def _load_template(self, template_name: str) -> Template: def _load_template(self, template_name: str) -> Template:
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2') template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
@@ -39,15 +52,13 @@ class PromptManager:
with open(template_path, 'r') as file: with open(template_path, 'r') as file:
return Template(file.read()) return Template(file.read())
@property def get_system_message(self) -> str:
def system_message(self) -> str:
rendered = self.system_template.render( rendered = self.system_template.render(
agent_skills_docs=self.agent_skills_docs, agent_skills_docs=self.agent_skills_docs,
).strip() ).strip()
return rendered return rendered
@property def get_example_user_message(self) -> str:
def initial_user_message(self) -> str:
"""This is the initial user message provided to the agent """This is the initial user message provided to the agent
before *actual* user instructions are provided. before *actual* user instructions are provided.
@@ -57,7 +68,39 @@ class PromptManager:
These additional context will convert the current generic agent These additional context will convert the current generic agent
into a more specialized agent that is tailored to the user's task. into a more specialized agent that is tailored to the user's task.
""" """
rendered = self.user_template.render( return self.user_template.render().strip()
micro_agent=self.micro_agent.content if self.micro_agent else None
def enhance_message(self, message: Message) -> None:
"""Enhance the user message with additional context.
This method is used to enhance the user message with additional context
about the user's task. The additional context will convert the current
generic agent into a more specialized agent that is tailored to the user's task.
"""
if not message.content:
return
message_content = message.content[0].text
for microagent in self.microagents.values():
trigger = microagent.get_trigger(message_content)
if trigger:
micro_text = f'<extra_info>\nThe following information has been included based on a keyword match for "{trigger}". It may or may not be relevant to the user\'s request.'
micro_text += '\n\n' + microagent.content
micro_text += '\n</extra_info>'
message.content.append(TextContent(text=micro_text))
def add_turns_left_reminder(self, messages: list[Message], state: State) -> None:
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
) )
return rendered.strip() if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
Generated
+1525 -1474
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "openhands-ai" name = "openhands-ai"
version = "0.12.3" version = "0.13.0"
description = "OpenHands: Code Less, Make More" description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"] authors = ["OpenHands"]
license = "MIT" license = "MIT"
@@ -61,6 +61,8 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0" opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0" opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = "^0.64.145" modal = "^0.64.145"
runloop-api-client = "0.7.0"
pygithub = "^2.5.0"
[tool.poetry.group.llama-index.dependencies] [tool.poetry.group.llama-index.dependencies]
llama-index = "*" llama-index = "*"
+3
View File
@@ -14,6 +14,7 @@ from openhands.events import EventStream
from openhands.runtime.base import Runtime from openhands.runtime.base import Runtime
from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
from openhands.storage import get_file_store from openhands.storage import get_file_store
from openhands.utils.async_utils import call_async_from_sync from openhands.utils.async_utils import call_async_from_sync
@@ -131,6 +132,8 @@ def get_runtime_classes():
return [EventStreamRuntime] return [EventStreamRuntime]
elif runtime.lower() == 'remote': elif runtime.lower() == 'remote':
return [RemoteRuntime] return [RemoteRuntime]
elif runtime.lower() == 'runloop':
return [RunloopRuntime]
else: else:
raise ValueError(f'Invalid runtime: {runtime}') raise ValueError(f'Invalid runtime: {runtime}')

Some files were not shown because too many files have changed in this diff Show More