Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac8a35811 | |||
| 9d3c6d87fb | |||
| 7df7f43e3c | |||
| 4c935a84e7 | |||
| 2ad0831560 | |||
| a45aba512a | |||
| d865f1e4a7 | |||
| a1a9d2f175 | |||
| 79492b6551 | |||
| 80fdb9a2f4 | |||
| a38c45cf75 | |||
| 975e75531d | |||
| 1b5f5bcdad | |||
| 8c00d96024 | |||
| bf8ccc8fc3 | |||
| 037d770f66 | |||
| dd50246672 | |||
| 090771674c | |||
| d8ab0208ba | |||
| a07e8272da | |||
| be82832eb1 | |||
| 67c8915d51 | |||
| 40b3ccb17c | |||
| 35c68863dc | |||
| 8bfee87bcf | |||
| e1383afbc3 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)!
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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,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 {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 = "*"
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|
||||||
|
|||||||