Compare commits

..

34 Commits

Author SHA1 Message Date
openhands
eed35ba34b Move rate limiter to middleware.py and use in-memory store
- Move rate limiter classes to middleware.py
- Use in-memory store instead of Redis
- Keep same rate limits:
  * Default: 2 req/sec
  * Static files: 10 req/sec
  * WebSocket and authenticate endpoints: 1 req/5s
2024-11-12 21:32:03 +00:00
openhands
9ec47bee73 Move rate limiter to middleware.py and use in-memory store
- Move rate limiter classes to middleware.py
- Use in-memory store instead of Redis
- Keep same rate limits:
  * Default: 2 req/sec
  * Static files: 10 req/sec
  * WebSocket and authenticate endpoints: 1 req/5s
2024-11-12 21:27:12 +00:00
openhands
17eedeeba9 Switch to in-memory rate limiter implementation
- Remove Redis dependency
- Implement custom in-memory rate limiter with thread-safe storage
- Keep same rate limits:
  * Default: 2 req/sec
  * Static files: 10 req/sec
  * WebSocket and authenticate endpoints: 1 req/5s
2024-11-12 21:22:32 +00:00
openhands
253e19c66c Add type ignore for redis import 2024-11-12 21:19:34 +00:00
openhands
f6742c5af7 Apply code formatting changes 2024-11-12 21:19:09 +00:00
openhands
ce9963db01 Add rate limiting to FastAPI server
- Default rate limit: 2 req/sec
- Static files: 10 req/sec
- WebSocket endpoint: 1 req/5s
- Authenticate endpoint: 1 req/5s

Uses fastapi-limiter with Redis backend for rate limiting implementation.
2024-11-12 21:13:19 +00:00
tofarr
59f7093428 Fix max iterations (#4949) 2024-11-12 21:09:43 +00:00
sp.wack
123fb4b75d feat(posthog): Add saas login event (#4948) 2024-11-12 20:37:59 +00:00
mamoodi
40e2d28e87 Release 0.13.1 (#4947) 2024-11-12 15:08:10 -05:00
OpenHands
c555611d58 Fix issue #4941: [Bug]: Browser tab does not reset after starting a new session (#4945)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-12 19:40:12 +00:00
Calvin Smith
50e7da9c3d fix(evaluation): SWE-bench evaluation script supports multiprocessing (#4943) 2024-11-12 12:19:57 -07:00
sp.wack
0cfb132ab7 fix(frontend): Remove dotted outline on focus (#4926) 2024-11-12 18:27:06 +02:00
Robert Brennan
17f4c6e1a9 Refactor sessions a bit, and fix issue where runtimes get killed (#4900) 2024-11-12 16:20:36 +00:00
Xingyao Wang
910b283ac2 fix(llm): bedrock throw errors if content contains empty string (#4935) 2024-11-12 15:53:22 +00:00
OpenHands
b54724ac3f Fix issue #4931: Make use of microagents configurable in codeact_agent (#4932)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-12 15:42:13 +00:00
Robert Brennan
0633a99298 Fix resume runtime after a pause (#4904) 2024-11-12 09:03:02 -05:00
Ryan H. Tran
d9c5f11046 Replace file editor with openhands-aci (#4782) 2024-11-12 21:26:33 +08:00
Engel Nyst
32fdcd58e5 Update litellm (#4927) 2024-11-12 11:24:19 +00:00
sp.wack
de71b7cdb8 test(frontend): Fix failing e2e test due to mock delay (#4923) 2024-11-12 10:50:38 +00:00
sp.wack
04aeccfb69 fix(frontend): Remove quotes from suggestion (#4921) 2024-11-12 12:30:43 +02:00
Faraz Shamim
4eea1286d4 Issue #4399 : Replaced all occurences (#4878)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-12 10:58:09 +01:00
Robert Brennan
488a320ffd update to use github client lib (#4909) 2024-11-12 00:56:50 +00:00
Robert Brennan
377fadc2eb fix remote runtimes (#4902) 2024-11-12 00:02:34 +00:00
Robert Brennan
7df7f43e3c Revert "Add rate limiting to server endpoints" (#4910) 2024-11-11 23:26:49 +00:00
Engel Nyst
a45aba512a Tweak log levels (#4729) 2024-11-11 22:51:56 +00:00
tofarr
a1a9d2f175 Refactor websocket (#4879)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-11 22:36:07 +00:00
Robert Brennan
79492b6551 Add rate limiting to server endpoints (#4867)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-11 16:54:22 -05:00
sp.wack
80fdb9a2f4 feat(posthog): Emit user activated event (#4886) 2024-11-11 23:31:41 +02:00
Nafis Reza
975e75531d Move assets/icons to dedicated folder (#4850) 2024-11-11 20:17:04 +00:00
Robert Brennan
1b5f5bcdad fixes for upcoming changes to remote API (#4834) 2024-11-11 14:51:14 -05:00
Rohit Malhotra
8c00d96024 Support displaying images/videos/pdfs in the workspace (#4898) 2024-11-11 20:22:17 +02:00
Robert Brennan
bf8ccc8fc3 fix infinite loop (#4873)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-11 10:59:43 +00:00
OpenHands
037d770f66 Fix issue #4884: (chore) add missing FE translations (#4885)
Co-authored-by: tobitege <10787084+tobitege@users.noreply.github.com>
2024-11-11 10:09:46 +00:00
sp.wack
dd50246672 test(frontend): Pass failing tests (#4887) 2024-11-11 09:49:56 +00:00
113 changed files with 1492 additions and 1839 deletions

View File

@@ -286,7 +286,6 @@ jobs:
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
SKIP_CONTAINER_LOGS=true \
TEST_RUNTIME=eventstream \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
@@ -364,7 +363,6 @@ jobs:
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
SKIP_CONTAINER_LOGS=true \
TEST_RUNTIME=eventstream \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \

View File

@@ -44,6 +44,7 @@ docker run -it --pull=always \
-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 \
-p 3000:3000 \
-e LOG_ALL_EVENTS=true \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13

View File

@@ -49,6 +49,7 @@ docker run -it \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \
-e LOG_ALL_EVENTS=true \
-v $WORKSPACE_BASE:/opt/workspace_base \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \

View File

@@ -17,6 +17,7 @@ docker run -it --rm --pull=always \
-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 \
-p 3000:3000 \
-e LOG_ALL_EVENTS=true \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13

View File

@@ -59,7 +59,7 @@ docker run # ...
-e RUNTIME=remote \
-e SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.app.all-hands.dev" \
-e SANDBOX_API_KEY="your-all-hands-api-key" \
-e SANDBOX_KEEP_REMOTE_RUNTIME_ALIVE="true" \
-e SANDBOX_KEEP_RUNTIME_ALIVE="true" \
# ...
```

View File

@@ -66,7 +66,7 @@ def get_config(
browsergym_eval_env=env_id,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_remote_runtime_alive=False,
keep_runtime_alive=False,
),
# do not mount workspace
workspace_base=None,

View File

@@ -72,7 +72,7 @@ def get_config(
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_remote_runtime_alive=False,
keep_runtime_alive=False,
),
# do not mount workspace
workspace_base=None,

View File

@@ -1,6 +1,7 @@
import os
import tempfile
import time
from functools import partial
import pandas as pd
from swebench.harness.grading import get_eval_report
@@ -94,13 +95,28 @@ def get_config(instance: pd.Series) -> AppConfig:
def process_instance(
instance: pd.Series,
metadata: EvalMetadata | None = None,
metadata: EvalMetadata,
reset_logger: bool = True,
log_dir: str | None = None,
) -> EvalOutput:
"""
Evaluate agent performance on a SWE-bench problem instance.
Note that this signature differs from the expected input to `run_evaluation`. Use
`functools.partial` to provide optional arguments before passing to the evaluation harness.
Args:
log_dir (str | None, default=None): Path to directory where log files will be written. Must
be provided if `reset_logger` is set.
Raises:
AssertionError: if the `reset_logger` flag is set without a provided log directory.
"""
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
global output_file
log_dir = output_file.replace('.jsonl', '.logs')
assert (
log_dir is not None
), "Can't reset logger without a provided log directory."
os.makedirs(log_dir, exist_ok=True)
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
else:
@@ -127,6 +143,7 @@ def process_instance(
return EvalOutput(
instance_id=instance_id,
test_result=instance['test_result'],
metadata=metadata,
)
runtime = create_runtime(config)
@@ -176,6 +193,7 @@ def process_instance(
return EvalOutput(
instance_id=instance_id,
test_result=instance['test_result'],
metadata=metadata,
)
elif 'APPLY_PATCH_PASS' in apply_patch_output:
logger.info(f'[{instance_id}] {APPLY_PATCH_PASS}:\n{apply_patch_output}')
@@ -269,6 +287,7 @@ def process_instance(
return EvalOutput(
instance_id=instance_id,
test_result=instance['test_result'],
metadata=metadata,
)
else:
logger.info(
@@ -355,12 +374,26 @@ if __name__ == '__main__':
output_file = args.input_file.replace('.jsonl', '.swebench_eval.jsonl')
instances = prepare_dataset(predictions, output_file, args.eval_n_limit)
# If possible, load the relevant metadata to avoid issues with `run_evaluation`.
metadata: EvalMetadata | None = None
metadata_filepath = os.path.join(os.path.dirname(args.input_file), 'metadata.json')
if os.path.exists(metadata_filepath):
with open(metadata_filepath, 'r') as metadata_file:
data = metadata_file.read()
metadata = EvalMetadata.model_validate_json(data)
# The evaluation harness constrains the signature of `process_instance_func` but we need to
# pass extra information. Build a new function object to avoid issues with multiprocessing.
process_instance_func = partial(
process_instance, log_dir=output_file.replace('.jsonl', '.logs')
)
run_evaluation(
instances,
metadata=None,
metadata=metadata,
output_file=output_file,
num_workers=args.eval_num_workers,
process_instance_func=process_instance,
process_instance_func=process_instance_func,
)
# Load evaluated predictions & print number of resolved predictions

View File

@@ -145,7 +145,7 @@ def get_config(
platform='linux/amd64',
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_remote_runtime_alive=False,
keep_runtime_alive=False,
remote_runtime_init_timeout=1800,
),
# do not mount workspace

View File

@@ -346,6 +346,7 @@ def run_evaluation(
f'model {metadata.llm_config.model}, max iterations {metadata.max_iterations}.\n'
)
else:
logger.warning('Running evaluation without metadata.')
logger.info(f'Evaluation started with {num_workers} workers.')
total_instances = len(dataset)

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { clearSession } from "../src/utils/clear-session";
import store from "../src/store";
import { initialState as browserInitialState } from "../src/state/browserSlice";
describe("clearSession", () => {
beforeEach(() => {
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
vi.stubGlobal("localStorage", localStorageMock);
// Set initial browser state to non-default values
store.dispatch({
type: "browser/setUrl",
payload: "https://example.com",
});
store.dispatch({
type: "browser/setScreenshotSrc",
payload: "base64screenshot",
});
});
it("should clear localStorage and reset browser state", () => {
clearSession();
// Verify localStorage items were removed
expect(localStorage.removeItem).toHaveBeenCalledWith("token");
expect(localStorage.removeItem).toHaveBeenCalledWith("repo");
// Verify browser state was reset
const state = store.getState();
expect(state.browser.url).toBe(browserInitialState.url);
expect(state.browser.screenshotSrc).toBe(browserInitialState.screenshotSrc);
});
});

View File

@@ -16,14 +16,14 @@ describe("Empty state", () => {
send: vi.fn(),
}));
const { useSocket: useSocketMock } = vi.hoisted(() => ({
useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
}));
beforeAll(() => {
vi.mock("#/context/socket", async (importActual) => ({
...(await importActual<typeof import("#/context/socket")>()),
useSocket: useSocketMock,
...(await importActual<typeof import("#/context/ws-client-provider")>()),
useWsClient: useWsClientMock,
}));
});
@@ -77,7 +77,7 @@ describe("Empty state", () => {
"should load the a user message to the input when selecting",
async () => {
// this is to test that the message is in the UI before the socket is called
useSocketMock.mockImplementation(() => ({
useWsClientMock.mockImplementation(() => ({
send: sendMock,
runtimeActive: false, // mock an inactive runtime setup
}));
@@ -106,7 +106,7 @@ describe("Empty state", () => {
it.fails(
"should send the message to the socket only if the runtime is active",
async () => {
useSocketMock.mockImplementation(() => ({
useWsClientMock.mockImplementation(() => ({
send: sendMock,
runtimeActive: false, // mock an inactive runtime setup
}));
@@ -123,7 +123,7 @@ describe("Empty state", () => {
await user.click(displayedSuggestions[0]);
expect(sendMock).not.toHaveBeenCalled();
useSocketMock.mockImplementation(() => ({
useWsClientMock.mockImplementation(() => ({
send: sendMock,
runtimeActive: true, // mock an active runtime setup
}));

View File

@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { afterEach } from "node:test";
import { useTerminal } from "#/hooks/useTerminal";
import { SocketProvider } from "#/context/socket";
import { Command } from "#/state/commandSlice";
import { WsClientProvider } from "#/context/ws-client-provider";
import { ReactNode } from "react";
interface TestTerminalComponentProps {
commands: Command[];
@@ -18,6 +19,17 @@ function TestTerminalComponent({
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", () => {
const mockTerminal = vi.hoisted(() => ({
loadAddon: vi.fn(),
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
it("should render", () => {
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
wrapper: SocketProvider,
wrapper: Wrapper,
});
});
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
];
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
wrapper: SocketProvider,
wrapper: Wrapper,
});
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
secrets={[secret, anotherSecret]}
/>,
{
wrapper: SocketProvider,
wrapper: Wrapper,
},
);

View File

@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
separator: "/",
});
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({
expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
provider: "anthropic",
model: "claude-3-5-sonnet-20241022",
model: "claude-3-5-sonnet-20240620",
separator: "/",
});

View File

@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
"gpt-4o",
"together-ai-21.1b-41b",
"gpt-4o-mini",
"claude-3-5-sonnet-20241022",
"anthropic/claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
"claude-2",
"claude-2.1",

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",

View File

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

View File

@@ -1,11 +0,0 @@
> openhands-frontend@0.13.0 dev
> npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev
> openhands-frontend@0.13.0 make-i18n
> node scripts/make-i18n-translations.cjs
➜ Local: http://localhost:3001/
➜ Network: use --host to expose
➜ press h + enter to show help

View File

@@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { RootState } from "#/store";
import AgentState from "#/types/AgentState";
import { useSocket } from "#/context/socket";
import { useWsClient } from "#/context/ws-client-provider";
const IgnoreTaskStateMap: Record<string, AgentState[]> = {
[AgentState.PAUSED]: [
@@ -72,7 +72,7 @@ function ActionButton({
}
function AgentControlBar() {
const { send } = useSocket();
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const handleAction = (action: AgentState) => {

View File

@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react";
import Clip from "#/icons/clip.svg?react";
export function AttachImageLabel() {
return (

View File

@@ -1,6 +1,6 @@
import React from "react";
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";
interface ChatInputProps {

View File

@@ -1,7 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { useSocket } from "#/context/socket";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions";
@@ -21,14 +20,15 @@ import { ContinueButton } from "./continue-button";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { Suggestions } from "./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 = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
export function ChatInterface() {
const { send } = useSocket();
const { send } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =

View File

@@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject";
import { I18nKey } from "#/i18n/declaration";
import AgentState from "#/types/AgentState";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { useSocket } from "#/context/socket";
import { useWsClient } from "#/context/ws-client-provider";
interface ActionTooltipProps {
type: "confirm" | "reject";
@@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) {
function ConfirmationButtons() {
const { t } = useTranslation();
const { send } = useSocket();
const { send } = useWsClient();
const handleStateChange = (state: AgentState) => {
const event = generateAgentStateChangeEvent(state);

View File

@@ -0,0 +1,197 @@
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";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
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 (event.type === "error") {
const message: string = `${event.message}`;
if (message.startsWith("Agent reached maximum")) {
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
send(generateAgentStateChangeEvent(AgentState.PAUSED));
}
}
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;
}

View File

@@ -1,4 +1,4 @@
import CloseIcon from "#/assets/close.svg?react";
import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils";
interface ImagePreviewProps {

View File

@@ -59,11 +59,6 @@ export function InteractiveChatBox({
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
"transition-colors duration-200",
"hover:border-neutral-500 focus-within:border-neutral-500",
"group relative",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
"before:border-2 before:border-dashed before:border-transparent",
"[&:has(*:focus-within)]:before:border-neutral-500/50",
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
)}
>
<UploadImageInput onUpload={handleUpload} />

View File

@@ -1,5 +1,5 @@
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 ModalBody from "./ModalBody";
import { I18nKey } from "#/i18n/declaration";

View File

@@ -2,17 +2,17 @@ import React from "react";
import { useDispatch } from "react-redux";
import toast from "react-hot-toast";
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 { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
import { addUserMessage } from "#/state/chatSlice";
import { useSocket } from "#/context/socket";
import { createChatMessage } from "#/services/chatService";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace";
import { LoadingSpinner } from "../modals/LoadingProject";
import { useWsClient } from "#/context/ws-client-provider";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -27,7 +27,7 @@ export function ProjectMenuCard({
isConnectedToGitHub,
githubData,
}: ProjectMenuCardProps) {
const { send } = useSocket();
const { send } = useWsClient();
const dispatch = useDispatch();
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
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";
interface ProjectMenuDetailsPlaceholderProps {

View File

@@ -1,5 +1,5 @@
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 { I18nKey } from "#/i18n/declaration";

View File

@@ -1,4 +1,4 @@
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
interface ScrollToBottomButtonProps {
onClick: () => void;

View File

@@ -1,5 +1,5 @@
import Lightbulb from "#/assets/lightbulb.svg?react";
import Refresh from "#/assets/refresh.svg?react";
import Lightbulb from "#/icons/lightbulb.svg?react";
import Refresh from "#/icons/refresh.svg?react";
interface SuggestionBubbleProps {
suggestion: string;

View File

@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react";
import Clip from "#/icons/clip.svg?react";
interface UploadImageInputProps {
onUpload: (files: File[]) => void;

View File

@@ -1,5 +1,5 @@
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";
interface UserAvatarProps {

View File

@@ -1,146 +0,0 @@
import React from "react";
import { Data } from "ws";
import posthog from "posthog-js";
import EventLogger from "#/utils/event-logger";
interface WebSocketClientOptions {
token: string | null;
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent<Data>) => void;
onError?: (event: Event) => void;
onClose?: (event: Event) => void;
}
interface WebSocketContextType {
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
start: (options?: WebSocketClientOptions) => void;
stop: () => void;
setRuntimeIsInitialized: () => void;
runtimeActive: boolean;
isConnected: boolean;
events: Record<string, unknown>[];
}
const SocketContext = React.createContext<WebSocketContextType | undefined>(
undefined,
);
interface SocketProviderProps {
children: React.ReactNode;
}
function SocketProvider({ children }: SocketProviderProps) {
const wsRef = React.useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = React.useState(false);
const [runtimeActive, setRuntimeActive] = React.useState(false);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const setRuntimeIsInitialized = () => {
setRuntimeActive(true);
};
const start = React.useCallback((options?: WebSocketClientOptions): void => {
if (wsRef.current) {
EventLogger.warning(
"WebSocket connection is already established, but a new one is starting anyways.",
);
}
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated
const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB";
const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
sessionToken,
ghToken,
]);
ws.addEventListener("open", (event) => {
posthog.capture("socket_opened");
setIsConnected(true);
options?.onOpen?.(event);
});
ws.addEventListener("message", (event) => {
EventLogger.message(event);
setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]);
options?.onMessage?.(event);
});
ws.addEventListener("error", (event) => {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
options?.onError?.(event);
});
ws.addEventListener("close", (event) => {
posthog.capture("socket_closed");
EventLogger.event(event, "SOCKET CLOSE");
setIsConnected(false);
setRuntimeActive(false);
wsRef.current = null;
options?.onClose?.(event);
});
wsRef.current = ws;
}, []);
const stop = React.useCallback((): void => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}, []);
const send = React.useCallback(
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]);
wsRef.current.send(data);
},
[],
);
const value = React.useMemo(
() => ({
send,
start,
stop,
setRuntimeIsInitialized,
runtimeActive,
isConnected,
events,
}),
[
send,
start,
stop,
setRuntimeIsInitialized,
runtimeActive,
isConnected,
events,
],
);
return (
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
);
}
function useSocket() {
const context = React.useContext(SocketContext);
if (context === undefined) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
}
export { SocketProvider, useSocket };

View File

@@ -0,0 +1,175 @@
import posthog from "posthog-js";
import React from "react";
import { Settings } from "#/services/settings";
import ActionType from "#/types/ActionType";
import EventLogger from "#/utils/event-logger";
import AgentState from "#/types/AgentState";
export enum WsClientProviderStatus {
STOPPED,
OPENING,
ACTIVE,
ERROR,
}
interface UseWsClient {
status: WsClientProviderStatus;
events: Record<string, unknown>[];
send: (event: Record<string, unknown>) => void;
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.STOPPED,
events: [],
send: () => {
throw new Error("not connected");
},
});
interface WsClientProviderProps {
enabled: boolean;
token: string | null;
ghToken: string | null;
settings: Settings | null;
}
export function WsClientProvider({
enabled,
token,
ghToken,
settings,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const wsRef = React.useRef<WebSocket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
function send(event: Record<string, unknown>) {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
wsRef.current.send(JSON.stringify(event));
}
function handleOpen() {
setStatus(WsClientProviderStatus.OPENING);
const initEvent = {
action: ActionType.INIT,
args: settings,
};
send(initEvent);
}
function handleMessage(messageEvent: MessageEvent) {
const event = JSON.parse(messageEvent.data);
setEvents((prevEvents) => [...prevEvents, event]);
if (event.extras?.agent_state === AgentState.INIT) {
setStatus(WsClientProviderStatus.ACTIVE);
}
if (
status !== WsClientProviderStatus.ACTIVE &&
event?.observation === "error"
) {
setStatus(WsClientProviderStatus.ERROR);
}
}
function handleClose() {
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
wsRef.current = null;
}
function handleError(event: Event) {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
setStatus(WsClientProviderStatus.ERROR);
}
// Connect websocket
React.useEffect(() => {
let ws = wsRef.current;
// If disabled close any existing websockets...
if (!enabled) {
if (ws) {
ws.close();
}
wsRef.current = null;
return () => {};
}
// If there is no websocket or the tokens have changed or the current websocket is closed,
// create a new one
if (
!ws ||
(tokenRef.current && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current ||
ws.readyState === WebSocket.CLOSED ||
ws.readyState === WebSocket.CLOSING
) {
ws?.close();
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
token || "NO_JWT",
ghToken || "NO_GITHUB",
]);
}
ws.addEventListener("open", handleOpen);
ws.addEventListener("message", handleMessage);
ws.addEventListener("error", handleError);
ws.addEventListener("close", handleClose);
wsRef.current = ws;
tokenRef.current = token;
ghTokenRef.current = ghToken;
return () => {
ws.removeEventListener("open", handleOpen);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.removeEventListener("close", handleClose);
};
}, [enabled, token, ghToken]);
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
// before actually closing the socket and cancel the operation if the component gets remounted.
React.useEffect(() => {
const timeout = closeRef.current;
if (timeout != null) {
clearTimeout(timeout);
}
return () => {
closeRef.current = setTimeout(() => {
wsRef.current?.close();
}, 100);
};
}, []);
const value = React.useMemo<UseWsClient>(
() => ({
status,
events,
send,
}),
[status, events],
);
return (
<WsClientContext.Provider value={value}>
{children}
</WsClientContext.Provider>
);
}
export function useWsClient() {
const context = React.useContext(WsClientContext);
return context;
}

View File

@@ -10,7 +10,6 @@ import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import { SocketProvider } from "./context/socket";
import "./i18n";
import store from "./store";
@@ -43,12 +42,10 @@ prepareApp().then(() =>
hydrateRoot(
document,
<StrictMode>
<SocketProvider>
<Provider store={store}>
<RemixBrowser />
<PosthogInit />
</Provider>
</SocketProvider>
<Provider store={store}>
<RemixBrowser />
<PosthogInit />
</Provider>
</StrictMode>,
);
}),

View File

@@ -4,7 +4,7 @@ import React from "react";
import { Command } from "#/state/commandSlice";
import { getTerminalCommand } from "#/services/terminalService";
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.
@@ -15,7 +15,7 @@ export const useTerminal = (
commands: Command[] = [],
secrets: string[] = [],
) => {
const { send } = useSocket();
const { send } = useWsClient();
const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
@@ -101,10 +101,6 @@ export const useTerminal = (
if (commandBuffer.length > 0) {
commandBuffer = handleBackspace(commandBuffer);
}
} else if (domEvent.key === "Tab") {
// Swallow tab key and convert to space
commandBuffer += " ";
terminal.current?.write(" ");
} else {
// Ignore paste event
if (key.charCodeAt(0) === 22) {

View File

@@ -535,7 +535,8 @@
"pt": "Socket não inicializado",
"ko-KR": "소켓이 초기화되지 않았습니다",
"ar": "لم يتم تهيئة Socket",
"tr": "Soket başlatılmadı"
"tr": "Soket başlatılmadı",
"no": "Socket ikke initialisert"
},
"EXPLORER$UPLOAD_ERROR_MESSAGE": {
"en": "Error uploading file",
@@ -548,7 +549,8 @@
"pt": "Erro ao fazer upload do arquivo",
"ko-KR": "파일 업로드 중 오류 발생",
"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": {
"en": "Drop files here",
@@ -557,6 +559,7 @@
"zh-TW": "將檔案拖曳至此",
"es": "Suelta los archivos aquí",
"fr": "Déposez les fichiers ici",
"no": "Slipp filer her",
"it": "Trascina i file qui",
"pt": "Solte os arquivos aqui",
"ko-KR": "파일을 여기에 놓으세요",
@@ -574,7 +577,8 @@
"pt": "Espaço de trabalho",
"ko-KR": "작업 공간",
"ar": "مساحة العمل",
"tr": "Çalışma alanı"
"tr": "Çalışma alanı",
"no": "Arbeidsområde"
},
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
"en": "No files in workspace",
@@ -587,7 +591,8 @@
"pt": "Nenhum arquivo no espaço de trabalho",
"ko-KR": "작업 공간에 파일이 없습니다",
"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": {
"en": "Loading workspace...",
@@ -600,7 +605,8 @@
"pt": "Carregando espaço de trabalho...",
"ko-KR": "작업 공간 로딩 중...",
"ar": "جارٍ تحميل مساحة العمل...",
"tr": "Çalışma alanı yükleniyor..."
"tr": "Çalışma alanı yükleniyor...",
"no": "Laster arbeidsområde..."
},
"EXPLORER$REFRESH_ERROR_MESSAGE": {
"en": "Error refreshing workspace",
@@ -613,7 +619,8 @@
"pt": "Erro ao atualizar o espaço de trabalho",
"ko-KR": "작업 공간 새로 고침 오류",
"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": {
"en": "Successfully uploaded {{count}} file(s)",
@@ -626,7 +633,8 @@
"pt": "{{count}} arquivo(s) carregado(s) com sucesso",
"ko-KR": "{{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": {
"en": "No files were uploaded",
@@ -639,7 +647,8 @@
"pt": "Nenhum arquivo foi carregado",
"ko-KR": "업로드된 파일이 없습니다",
"ar": "لم يتم تحميل أي ملفات",
"tr": "Hiçbir dosya yüklenmedi"
"tr": "Hiçbir dosya yüklenmedi",
"no": "Ingen filer ble lastet opp"
},
"EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": {
"en": "{{count}} file(s) were skipped during upload",
@@ -652,7 +661,8 @@
"pt": "{{count}} arquivo(s) foram ignorados durante o upload",
"ko-KR": "업로드 중 {{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": {
"en": "Unexpected response structure from server",
@@ -665,7 +675,8 @@
"pt": "Estrutura de resposta inesperada do servidor",
"ko-KR": "서버로부터 예상치 못한 응답 구조",
"ar": "بنية استجابة غير متوقعة من الخادم",
"tr": "Sunucudan beklenmeyen yanıt yapısı"
"tr": "Sunucudan beklenmeyen yanıt yapısı",
"no": "Uventet responsstruktur fra serveren"
},
"LOAD_SESSION$MODAL_TITLE": {
"en": "Return to existing session?",
@@ -799,95 +810,325 @@
},
"FEEDBACK$EMAIL_PLACEHOLDER": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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":{
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"en": "Starting up!",
@@ -1276,7 +1517,8 @@
"pt": "Conversa de chat",
"es": "Conversación de chat",
"ar": "محادثة تلقيم",
"fr": "Conversation de chat"
"fr": "Conversation de chat",
"tr": "Sohbet Konuşması"
},
"CHAT_INTERFACE$UNKNOWN_SENDER": {
"en": "Unknown",

View File

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 662 B

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 319 B

View File

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 924 B

After

Width:  |  Height:  |  Size: 924 B

View File

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 649 B

View File

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 856 B

View File

Before

Width:  |  Height:  |  Size: 811 B

After

Width:  |  Height:  |  Size: 811 B

View File

@@ -71,8 +71,6 @@ const openHandsHandlers = [
export const handlers = [
...openHandsHandlers,
http.get("https://api.github.com/user/repos", async ({ request }) => {
if (import.meta.env.MODE !== "test") await delay(3500);
const token = request.headers
.get("Authorization")
?.replace("Bearer", "")

View File

@@ -29,7 +29,7 @@ const generateAgentResponse = (message: string): AssistantMessageAction => ({
action: "message",
args: {
content: message,
images_urls: [],
image_urls: [],
wait_for_response: false,
},
});

View File

@@ -1,4 +1,4 @@
import BuildIt from "#/assets/build-it.svg?react";
import BuildIt from "#/icons/build-it.svg?react";
export function HeroHeading() {
return (

View File

@@ -70,11 +70,6 @@ export function TaskForm() {
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
"hover:border-neutral-500 focus-within:border-neutral-500",
"group relative",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
"before:border-2 before:border-dashed before:border-transparent",
"[&:has(*:focus-within)]:before:border-neutral-500/50",
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
)}
>
<ChatInput

View File

@@ -29,6 +29,10 @@ function CodeEditorCompoonent({
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(() => {
const handleSave = async (event: KeyboardEvent) => {
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 (
<Editor
data-testid="code-editor"
path={selectedPath ?? undefined}
defaultValue=""
value={
selectedPath
? modifiedFiles[selectedPath] || files[selectedPath]
: undefined
}
value={selectedPath ? fileContent : undefined}
onMount={onMount}
onChange={handleEditorChange}
options={{ readOnly: isReadOnly }}

View File

@@ -2,71 +2,29 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import {
Outlet,
useFetcher,
useLoaderData,
json,
ClientActionFunctionArgs,
useRouteLoaderData,
} from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import WebSocket from "ws";
import toast from "react-hot-toast";
import { useDispatch } from "react-redux";
import { getSettings } from "#/services/settings";
import Security from "../components/modals/security/Security";
import { Controls } from "#/components/controls";
import store, { RootState } from "#/store";
import store from "#/store";
import { Container } from "#/components/container";
import ActionType from "#/types/ActionType";
import { handleAssistantMessage } from "#/services/actions";
import {
addErrorMessage,
addUserMessage,
clearMessages,
} from "#/state/chatSlice";
import { useSocket } from "#/context/socket";
import {
getGitHubTokenCommand,
getCloneRepoCommand,
} from "#/services/terminalService";
import { clearMessages } from "#/state/chatSlice";
import { clearTerminal } from "#/state/commandSlice";
import { useEffectOnce } from "#/utils/use-effect-once";
import CodeIcon from "#/assets/code.svg?react";
import GlobeIcon from "#/assets/globe.svg?react";
import ListIcon from "#/assets/list-type-number.svg?react";
import { createChatMessage } from "#/services/chatService";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
import { clearInitialQuery } from "#/state/initial-query-slice";
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 { FilesProvider } from "#/context/files";
import { ErrorObservation } from "#/types/core/observations";
import { ChatInterface } from "#/components/chat-interface";
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";
const isAgentStateChange = (
data: object,
): data is { extras: { agent_state: AgentState } } =>
"extras" in data &&
data.extras instanceof Object &&
"agent_state" in data.extras;
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "#/components/event-handler";
export const clientLoader = async () => {
const ghToken = localStorage.getItem("ghToken");
@@ -116,174 +74,26 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
function App() {
const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const { start, send, setRuntimeIsInitialized, runtimeActive } = useSocket();
const { settings, token, ghToken, repo, q, lastCommit } =
const { settings, token, ghToken, lastCommit } =
useLoaderData<typeof clientLoader>();
const fetcher = useFetcher();
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const secrets = React.useMemo(
() => [ghToken, token].filter((secret) => secret !== null),
[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(
() => 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(() => {
// clear and restart the socket connection
dispatch(clearMessages());
dispatch(clearTerminal());
dispatch(clearJupyter());
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 {
isOpen: securityModalIsOpen,
onOpen: onSecurityModalOpen,
@@ -291,53 +101,62 @@ function App() {
} = useDisclosure();
return (
<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>
<WsClientProvider
enabled
token={token}
ghToken={ghToken}
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">
<Container
className="h-2/3"
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: "Browser",
to: "browser",
icon: <GlobeIcon />,
isBeta: true,
},
]}
>
<FilesProvider>
<Outlet />
</FilesProvider>
</Container>
{/* 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. */}
<Container className="h-1/3 overflow-scroll" label="Terminal">
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>
</Container>
<div className="flex flex-col grow gap-3">
<Container
className="h-2/3"
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: "Browser",
to: "browser",
icon: <GlobeIcon />,
isBeta: true,
},
]}
>
<FilesProvider>
<Outlet />
</FilesProvider>
</Container>
{/* 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. */}
<Container className="h-1/3 overflow-scroll" label="Terminal">
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>
</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 className="h-[60px]">
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={lastCommit}
/>
</div>
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
</div>
</EventHandler>
</WsClientProvider>
);
}

View File

@@ -21,12 +21,11 @@ import { DangerModal } from "#/components/modals/confirmation-modals/danger-moda
import { LoadingSpinner } from "#/components/modals/LoadingProject";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { UserActions } from "#/components/user-actions";
import { useSocket } from "#/context/socket";
import i18n from "#/i18n";
import { getSettings, settingsAreUpToDate } from "#/services/settings";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/assets/new-project.svg?react";
import DocsIcon from "#/assets/docs.svg?react";
import NewProjectIcon from "#/icons/new-project.svg?react";
import DocsIcon from "#/icons/docs.svg?react";
import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal";
@@ -135,7 +134,6 @@ type SettingsFormData = {
};
export default function MainApp() {
const { stop, isConnected } = useSocket();
const navigation = useNavigation();
const location = useLocation();
const {
@@ -173,6 +171,8 @@ export default function MainApp() {
company: user.company,
name: user.name,
email: user.email,
user: user.login,
mode: window.__APP_MODE__ || "oss",
});
}
}, [user]);
@@ -202,14 +202,6 @@ export default function MainApp() {
}
}, [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 = () => {
logoutFetcher.submit(
{},
@@ -313,11 +305,9 @@ export default function MainApp() {
<p className="text-xs text-[#A3A3A3]">
To continue, connect an OpenAI, Anthropic, or other LLM account
</p>
{isConnected && (
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
)}
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
<SettingsForm
settings={settings}
models={settingsFormData.models}

View File

@@ -12,8 +12,11 @@ import {
import { setCurStatusMessage } from "#/state/statusSlice";
import store from "#/store";
import ActionType from "#/types/ActionType";
import { ActionMessage, StatusMessage } from "#/types/Message";
import { SocketMessage } from "#/types/ResponseType";
import {
ActionMessage,
ObservationMessage,
StatusMessage,
} from "#/types/Message";
import { handleObservationMessage } from "./observations";
const messageActions = {
@@ -138,22 +141,14 @@ export function handleStatusMessage(message: StatusMessage) {
}
}
export function handleAssistantMessage(data: string | SocketMessage) {
let socketMessage: SocketMessage;
if (typeof data === "string") {
socketMessage = JSON.parse(data) as SocketMessage;
export function handleAssistantMessage(message: Record<string, unknown>) {
if (message.action) {
handleActionMessage(message as unknown as ActionMessage);
} else if (message.observation) {
handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else {
socketMessage = data;
}
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);
console.error("Unknown message type", message);
}
}

View File

@@ -1,8 +1,7 @@
import ActionType from "#/types/ActionType";
import AgentState from "#/types/AgentState";
export const generateAgentStateChangeEvent = (state: AgentState) =>
JSON.stringify({
action: ActionType.CHANGE_AGENT_STATE,
args: { agent_state: state },
});
export const generateAgentStateChangeEvent = (state: AgentState) => ({
action: ActionType.CHANGE_AGENT_STATE,
args: { agent_state: state },
});

View File

@@ -63,7 +63,7 @@ export async function request(
} catch (e) {
onFail(`Error fetching ${url}`);
}
if (response?.status === 401) {
if (response?.status === 401 && !url.startsWith("/api/authenticate")) {
await request(
"/api/authenticate",
{

View File

@@ -2,12 +2,12 @@ import ActionType from "#/types/ActionType";
export function createChatMessage(
message: string,
images_urls: string[],
image_urls: string[],
timestamp: string,
) {
const event = {
action: ActionType.MESSAGE,
args: { content: message, images_urls, timestamp },
args: { content: message, image_urls, timestamp },
};
return JSON.stringify(event);
return event;
}

View File

@@ -2,7 +2,7 @@ import ActionType from "#/types/ActionType";
export function getTerminalCommand(command: string, hidden: boolean = false) {
const event = { action: ActionType.RUN, args: { command, hidden } };
return JSON.stringify(event);
return event;
}
export function getGitHubTokenCommand(gitHubToken: string) {

View File

@@ -4,7 +4,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
source: "user";
args: {
content: string;
images_urls: string[];
image_urls: string[];
};
}
@@ -23,7 +23,7 @@ export interface AssistantMessageAction
source: "agent";
args: {
content: string;
images_urls: string[] | null;
image_urls: string[] | null;
wait_for_response: boolean;
};
}

View File

@@ -27,7 +27,7 @@ interface LocalUserMessageAction {
action: "message";
args: {
content: string;
images_urls: string[];
image_urls: string[];
};
}

View File

@@ -1,7 +1,21 @@
import store from "#/store";
import { initialState as browserInitialState } from "#/state/browserSlice";
/**
* Clear the session data from the local storage. This will remove the token and repo
* Clear the session data from the local storage and reset relevant Redux state
*/
export const clearSession = () => {
// Clear local storage
localStorage.removeItem("token");
localStorage.removeItem("repo");
// Reset browser state to initial values
store.dispatch({
type: "browser/setUrl",
payload: browserInitialState.url,
});
store.dispatch({
type: "browser/setScreenshotSrc",
payload: browserInitialState.screenshotSrc,
});
};

View File

@@ -26,6 +26,7 @@ import { extractModelAndProvider } from "./extractModelAndProvider";
*/
export const organizeModelsAndProviders = (models: string[]) => {
const object: Record<string, { separator: string; models: string[] }> = {};
models.forEach((model) => {
const {
separator,
@@ -45,5 +46,6 @@ export const organizeModelsAndProviders = (models: string[]) => {
}
object[key].models.push(modelId);
});
return object;
};

View File

@@ -13,14 +13,14 @@ const KEY_2 = "Auto-merge Dependabot PRs";
const VALUE_2 = `Please add a GitHub action to this repository which automatically merges pull requests from Dependabot so long as the tests are passing.`;
const KEY_3 = "Fix up my README";
const VALUE_3 = `"Please look at the README and make the following improvements, if they make sense:
const VALUE_3 = `Please look at the README and make the following improvements, if they make sense:
* correct any typos that you find
* add missing language annotations on codeblocks
* if there are references to other files or other sections of the README, turn them into links
* make sure the readme has an h1 title towards the top
* make sure any existing sections in the readme are appropriately separated with headings
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier"`;
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier`;
const KEY_4 = "Clean up my dependencies";
const VALUE_4 = `Examine the dependencies of the current codebase. Make sure you can run the code and any tests.

View File

@@ -20,6 +20,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2",
"claude-2.1",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",

View File

@@ -6,7 +6,7 @@ import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react";
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 =>
configureStore({
@@ -35,7 +35,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
return (
<Provider store={store}>
<SocketProvider>{children}</SocketProvider>
<WsClientProvider enabled={true} token={null} ghToken={null} settings={null}>{children}</WsClientProvider>
</Provider>
);
}

View File

@@ -103,15 +103,17 @@ class CodeActAgent(Agent):
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
disabled_microagents=self.config.disabled_microagents,
)
else:
self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
agent_skills_docs=AgentSkillsRequirement.documentation,
disabled_microagents=self.config.disabled_microagents,
)
self.pending_actions: deque[Action] = deque()
@@ -196,8 +198,8 @@ class CodeActAgent(Agent):
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
if self.llm.vision_is_active() and action.images_urls:
content.append(ImageContent(image_urls=action.images_urls))
if self.llm.vision_is_active() and action.image_urls:
content.append(ImageContent(image_urls=action.image_urls))
return [
Message(
role=role,

View File

@@ -95,9 +95,9 @@ class CodeActSWEAgent(Agent):
if (
self.llm.vision_is_active()
and isinstance(action, MessageAction)
and action.images_urls
and action.image_urls
):
content.append(ImageContent(image_urls=action.images_urls))
content.append(ImageContent(image_urls=action.image_urls))
return Message(
role='user' if action.source == 'user' else 'assistant', content=content

View File

@@ -1,5 +1,6 @@
import asyncio
import copy
import os
import traceback
from typing import Callable, ClassVar, Type
@@ -259,7 +260,11 @@ class AgentController:
observation_to_print.content = truncate_content(
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:
self.agent.llm.metrics.merge(observation.llm_metrics)
@@ -282,8 +287,12 @@ class AgentController:
action (MessageAction): The message action to handle.
"""
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(
'debug',
log_level,
str(action),
extra={'msg_type': 'ACTION', 'event_source': EventSource.USER},
)
@@ -497,7 +506,9 @@ class AgentController:
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):
"""Executes a single step of the delegate agent."""
@@ -663,7 +674,7 @@ class AgentController:
# sanity check
if start_id > end_id + 1:
self.log(
'debug',
'warning',
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
)
self.state.history = []
@@ -694,7 +705,7 @@ class AgentController:
# Match with most recent unmatched delegate action
if not delegate_action_ids:
self.log(
'error',
'warning',
f'Found AgentDelegateObservation without matching action at id={event.id}',
)
continue

View File

@@ -149,7 +149,7 @@ class State:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == 'user':
last_user_message = event.content
last_user_message_image_urls = event.images_urls
last_user_message_image_urls = event.image_urls
elif isinstance(event, AgentFinishAction):
if last_user_message is not None:
return last_user_message, None

View File

@@ -16,6 +16,8 @@ class AgentConfig:
memory_enabled: Whether long-term memory (embeddings) is enabled.
memory_max_threads: The maximum number of threads indexing at the same time for embeddings.
llm_config: The name of the llm config to use. If specified, this will override global llm config.
use_microagents: Whether to use microagents at all. Default is True.
disabled_microagents: A list of microagents to disable. Default is None.
"""
function_calling: bool = True
@@ -26,6 +28,8 @@ class AgentConfig:
memory_enabled: bool = False
memory_max_threads: int = 3
llm_config: str | None = None
use_microagents: bool = True
disabled_microagents: list[str] | None = None
def defaults_to_dict(self) -> dict:
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""

View File

@@ -36,7 +36,7 @@ class SandboxConfig:
remote_runtime_api_url: str = 'http://localhost:8000'
local_runtime_url: str = 'http://localhost'
keep_remote_runtime_alive: bool = True
keep_runtime_alive: bool = True
api_key: str | None = None
base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22' # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
runtime_container_image: str | None = None

View File

@@ -177,7 +177,7 @@ class SensitiveDataFilter(logging.Filter):
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."""
console_handler = logging.StreamHandler()
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
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."""
os.makedirs(log_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y-%m-%d')

View File

@@ -98,6 +98,13 @@ class Message(BaseModel):
content.extend(d)
ret: dict = {'content': content, 'role': self.role}
# pop content if it's empty
if not content or (
len(content) == 1
and content[0]['type'] == 'text'
and content[0]['text'] == ''
):
ret.pop('content')
if role_tool_with_prompt_caching:
ret['cache_control'] = {'type': 'ephemeral'}

View File

@@ -7,7 +7,7 @@ from openhands.events.action.action import Action, ActionSecurityRisk
@dataclass
class MessageAction(Action):
content: str
images_urls: list[str] | None = None
image_urls: list[str] | None = None
wait_for_response: bool = False
action: str = ActionType.MESSAGE
security_risk: ActionSecurityRisk | None = None
@@ -16,10 +16,18 @@ class MessageAction(Action):
def message(self) -> str:
return self.content
@property
def images_urls(self):
# Deprecated alias for backward compatibility
return self.image_urls
@images_urls.setter
def images_urls(self, value):
self.image_urls = value
def __str__(self) -> str:
ret = f'**MessageAction** (source={self.source})\n'
ret += f'CONTENT: {self.content}'
if self.images_urls:
for url in self.images_urls:
if self.image_urls:
for url in self.image_urls:
ret += f'\nIMAGE_URL: {url}'
return ret

View File

@@ -66,6 +66,10 @@ def action_from_dict(action: dict) -> Action:
if is_confirmed is not None:
args['confirmation_state'] = is_confirmed
# images_urls has been renamed to image_urls
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')
try:
decoded_action = action_class(**args)
if 'timeout' in action:

View File

@@ -101,7 +101,7 @@ def event_to_memory(event: 'Event', max_message_chars: int) -> dict:
d.pop('cause', None)
d.pop('timestamp', None)
d.pop('message', None)
d.pop('images_urls', None)
d.pop('image_urls', None)
# runnable actions have some extra fields used in the BE/FE, which should not be sent to the LLM
if 'args' in d:

View File

@@ -14,7 +14,9 @@ class DebugMixin:
messages = messages if isinstance(messages, list) else [messages]
debug_message = MESSAGE_SEPARATOR.join(
self._format_message_content(msg) for msg in messages if msg['content']
self._format_message_content(msg)
for msg in messages
if msg.get('content', None)
)
if debug_message:

View File

@@ -7,7 +7,9 @@ NOTE: this will be executed inside the docker sandbox.
import argparse
import asyncio
import base64
import io
import mimetypes
import os
import shutil
import tempfile
@@ -217,6 +219,33 @@ class ActionExecutor:
working_dir = self.bash_session.workdir
filepath = self._resolve_path(action.path, working_dir)
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:
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:

View File

@@ -0,0 +1,18 @@
import docker
def remove_all_containers(prefix: str):
docker_client = docker.from_env()
try:
containers = docker_client.containers.list(all=True)
for container in containers:
try:
if container.name.startswith(prefix):
container.remove(force=True)
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
except docker.errors.NotFound: # yes, this can happen!
pass

View File

@@ -1,8 +1,9 @@
import atexit
import os
from pathlib import Path
import tempfile
import threading
from functools import lru_cache
from pathlib import Path
from typing import Callable
from zipfile import ZipFile
@@ -35,6 +36,7 @@ from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.base import Runtime
from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.impl.eventstream.containers import remove_all_containers
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.request import send_request
@@ -42,6 +44,15 @@ from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
atexit.register(remove_all_runtime_containers)
class LogBuffer:
"""Synchronous buffer for Docker container logs.
@@ -114,8 +125,6 @@ class EventStreamRuntime(Runtime):
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
container_name_prefix = 'openhands-runtime-'
# Need to provide this method to allow inheritors to init the Runtime
# without initting the EventStreamRuntime.
def init_base_runtime(
@@ -158,7 +167,7 @@ class EventStreamRuntime(Runtime):
self.docker_client: docker.DockerClient = self._init_docker_client()
self.base_container_image = self.config.sandbox.base_container_image
self.runtime_container_image = self.config.sandbox.runtime_container_image
self.container_name = self.container_name_prefix + sid
self.container_name = CONTAINER_NAME_PREFIX + sid
self.container = None
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
@@ -173,10 +182,6 @@ class EventStreamRuntime(Runtime):
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
)
self.skip_container_logs = (
os.environ.get('SKIP_CONTAINER_LOGS', 'false').lower() == 'true'
)
self.init_base_runtime(
config,
event_stream,
@@ -189,7 +194,15 @@ class EventStreamRuntime(Runtime):
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
if not self.attach_to_existing:
try:
await call_sync_from_async(self._attach_to_container)
except docker.errors.NotFound as e:
if self.attach_to_existing:
self.log(
'error',
f'Container {self.container_name} not found.',
)
raise e
if self.runtime_container_image is None:
if self.base_container_image is None:
raise ValueError(
@@ -210,13 +223,12 @@ class EventStreamRuntime(Runtime):
await call_sync_from_async(self._init_container)
self.log('info', f'Container started: {self.container_name}')
else:
await call_sync_from_async(self._attach_to_container)
if not self.attach_to_existing:
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
await call_sync_from_async(self._wait_until_alive)
if not self.attach_to_existing:
self.log('info', 'Runtime is ready.')
@@ -227,7 +239,8 @@ class EventStreamRuntime(Runtime):
'debug',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
)
self.send_status_message(' ')
if not self.attach_to_existing:
self.send_status_message(' ')
@staticmethod
@lru_cache(maxsize=1)
@@ -332,13 +345,12 @@ class EventStreamRuntime(Runtime):
self.log('debug', f'Container started. Server url: {self.api_url}')
self.send_status_message('STATUS$CONTAINER_STARTED')
except docker.errors.APIError as e:
# check 409 error
if '409' in str(e):
self.log(
'warning',
f'Container {self.container_name} already exists. Removing...',
)
self._close_containers(rm_all_containers=True)
remove_all_containers(self.container_name)
return self._init_container()
else:
@@ -414,42 +426,18 @@ class EventStreamRuntime(Runtime):
Parameters:
- rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
"""
if self.log_buffer:
self.log_buffer.close()
if self.session:
self.session.close()
if self.attach_to_existing:
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
return
self._close_containers(rm_all_containers)
def _close_containers(self, rm_all_containers: bool = True):
try:
containers = self.docker_client.containers.list(all=True)
for container in containers:
try:
# If the app doesn't shut down properly, it can leave runtime containers on the system. This ensures
# that all 'openhands-sandbox-' containers are removed as well.
if rm_all_containers and container.name.startswith(
self.container_name_prefix
):
container.remove(force=True)
elif container.name == self.container_name:
if not self.skip_container_logs:
logs = container.logs(tail=1000).decode('utf-8')
self.log(
'debug',
f'==== Container logs on close ====\n{logs}\n==== End of container logs ====',
)
container.remove(force=True)
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
except docker.errors.NotFound: # yes, this can happen!
pass
close_prefix = (
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
remove_all_containers(close_prefix)
def run_action(self, action: Action) -> Observation:
if isinstance(action, FileEditAction):

View File

@@ -137,7 +137,8 @@ class RemoteRuntime(Runtime):
try:
response = self._send_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.sid}',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
is_retry=False,
timeout=5,
)
except requests.HTTPError as e:
@@ -168,6 +169,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
is_retry=False,
timeout=10,
)
response_json = response.json()
@@ -198,6 +200,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
is_retry=False,
params={'image': self.container_image},
timeout=10,
)
@@ -227,13 +230,14 @@ class RemoteRuntime(Runtime):
'command': command,
'working_dir': '/openhands/code/',
'environment': {'DEBUG': 'true'} if self.config.debug else {},
'runtime_id': self.sid,
'session_id': self.sid,
}
# Start the sandbox using the /start endpoint
response = self._send_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/start',
is_retry=False,
json=start_request,
)
self._parse_runtime_response(response)
@@ -246,6 +250,7 @@ class RemoteRuntime(Runtime):
self._send_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/resume',
is_retry=False,
json={'runtime_id': self.runtime_id},
timeout=30,
)
@@ -276,21 +281,18 @@ class RemoteRuntime(Runtime):
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
runtime_info_response = self._send_request(
'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()
assert 'runtime_id' in runtime_data
assert runtime_data['runtime_id'] == self.runtime_id
assert 'pod_status' in runtime_data
pod_status = runtime_data['pod_status']
self.log('debug', f'Pod status: {pod_status}')
# FIXME: We should fix it at the backend of /start endpoint, make sure
# the pod is created before returning the response.
# Retry a period of time to give the cluster time to start the pod
if pod_status == 'Not Found':
raise RuntimeNotReadyError(
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
)
if pod_status == 'Ready':
try:
self._send_request(
@@ -305,12 +307,23 @@ class RemoteRuntime(Runtime):
f'Runtime /alive failed to respond with 200: {e}'
)
return
if pod_status in ('Failed', 'Unknown'):
elif (
pod_status == 'Not Found'
or pod_status == 'Pending'
or pod_status == 'Running'
): # nb: Running is not yet Ready
raise RuntimeNotReadyError(
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
)
elif pod_status in ('Failed', 'Unknown'):
# clean up the runtime
self.close()
raise RuntimeError(
f'Runtime (ID={self.runtime_id}) failed to start. Current status: {pod_status}'
)
else:
# Maybe this should be a hard failure, but passing through in case the API changes
self.log('warning', f'Unknown pod status: {pod_status}')
self.log(
'debug',
@@ -319,7 +332,7 @@ class RemoteRuntime(Runtime):
raise RuntimeNotReadyError()
def close(self, timeout: int = 10):
if self.config.sandbox.keep_remote_runtime_alive or self.attach_to_existing:
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
self.session.close()
return
if self.runtime_id and self.session:
@@ -327,6 +340,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/stop',
is_retry=False,
json={'runtime_id': self.runtime_id},
timeout=timeout,
)
@@ -342,7 +356,7 @@ class RemoteRuntime(Runtime):
finally:
self.session.close()
def run_action(self, action: Action) -> Observation:
def run_action(self, action: Action, is_retry: bool = False) -> Observation:
if action.timeout is None:
action.timeout = self.config.sandbox.timeout
if isinstance(action, FileEditAction):
@@ -367,6 +381,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'POST',
f'{self.runtime_url}/execute_action',
is_retry=False,
json=request_body,
# wait a few more seconds to get the timeout error from client side
timeout=action.timeout + 5,
@@ -380,7 +395,7 @@ class RemoteRuntime(Runtime):
)
return obs
def _send_request(self, method, url, **kwargs):
def _send_request(self, method, url, is_retry=False, **kwargs):
is_runtime_request = self.runtime_url and self.runtime_url in url
try:
return send_request(self.session, method, url, **kwargs)
@@ -392,6 +407,15 @@ class RemoteRuntime(Runtime):
raise RuntimeDisconnectedError(
f'404 error while connecting to {self.runtime_url}'
)
elif is_runtime_request and e.response.status_code == 503:
if not is_retry:
self.log('warning', 'Runtime appears to be paused. Resuming...')
self._resume_runtime()
self._wait_until_alive()
return self._send_request(method, url, True, **kwargs)
else:
raise e
else:
raise e
@@ -444,6 +468,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'POST',
f'{self.runtime_url}/upload_file',
is_retry=False,
files=upload_data,
params=params,
timeout=300,
@@ -467,6 +492,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'POST',
f'{self.runtime_url}/list_files',
is_retry=False,
json=data,
timeout=30,
)
@@ -480,6 +506,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'GET',
f'{self.runtime_url}/download_files',
is_retry=False,
params=params,
stream=True,
timeout=30,

View File

@@ -21,6 +21,8 @@ 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
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
class RunloopLogBuffer(LogBuffer):
"""Synchronous buffer for Runloop devbox logs.
@@ -115,7 +117,7 @@ class RunloopRuntime(EventStreamRuntime):
bearer_token=config.runloop_api_key,
)
self.session = requests.Session()
self.container_name = self.container_name_prefix + sid
self.container_name = CONTAINER_NAME_PREFIX + sid
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self.init_base_runtime(
config,
@@ -190,7 +192,7 @@ class RunloopRuntime(EventStreamRuntime):
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port],
resource_size_request="LARGE",
resource_size_request='LARGE',
),
metadata={'container-name': self.container_name},
)

View File

@@ -1,60 +1,8 @@
"""This file contains a global singleton of the `EditTool` class as well as raw functions that expose its __call__."""
from .base import CLIResult, ToolError, ToolResult
from .impl import Command, EditTool
_GLOBAL_EDITOR = EditTool()
def _make_api_tool_result(
result: ToolResult,
) -> str:
"""Convert an agent ToolResult to an API ToolResultBlockParam."""
tool_result_content: str = ''
is_error = False
if result.error:
is_error = True
tool_result_content = _maybe_prepend_system_tool_result(result, result.error)
else:
assert result.output, 'Expecting output in file_editor'
tool_result_content = _maybe_prepend_system_tool_result(result, result.output)
assert (
not result.base64_image
), 'Not expecting base64_image as output in file_editor'
if is_error:
return f'ERROR:\n{tool_result_content}'
else:
return tool_result_content
def _maybe_prepend_system_tool_result(result: ToolResult, result_text: str) -> str:
if result.system:
result_text = f'<system>{result.system}</system>\n{result_text}'
return result_text
def file_editor(
command: Command,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
) -> str:
try:
result: CLIResult = _GLOBAL_EDITOR(
command=command,
path=path,
file_text=file_text,
view_range=view_range,
old_str=old_str,
new_str=new_str,
insert_line=insert_line,
)
except ToolError as e:
return _make_api_tool_result(ToolResult(error=e.message))
return _make_api_tool_result(result)
"""This file imports a global singleton of the `EditTool` class as well as raw functions that expose
its __call__.
The implementation of the `EditTool` class can be found at: https://github.com/All-Hands-AI/openhands-aci/.
"""
from openhands_aci.editor import file_editor
__all__ = ['file_editor']

View File

@@ -1,50 +0,0 @@
from dataclasses import dataclass, fields, replace
@dataclass(kw_only=True, frozen=True)
class ToolResult:
"""Represents the result of a tool execution."""
output: str | None = None
error: str | None = None
base64_image: str | None = None
system: str | None = None
def __bool__(self):
return any(getattr(self, field.name) for field in fields(self))
def __add__(self, other: 'ToolResult'):
def combine_fields(
field: str | None, other_field: str | None, concatenate: bool = True
):
if field and other_field:
if concatenate:
return field + other_field
raise ValueError('Cannot combine tool results')
return field or other_field
return ToolResult(
output=combine_fields(self.output, other.output),
error=combine_fields(self.error, other.error),
base64_image=combine_fields(self.base64_image, other.base64_image, False),
system=combine_fields(self.system, other.system),
)
def replace(self, **kwargs):
"""Returns a new ToolResult with the given fields replaced."""
return replace(self, **kwargs)
class CLIResult(ToolResult):
"""A ToolResult that can be rendered as a CLI output."""
class ToolFailure(ToolResult):
"""A ToolResult that represents a failure."""
class ToolError(Exception):
"""Raised when a tool encounters an error."""
def __init__(self, message):
self.message = message

View File

@@ -1,279 +0,0 @@
from collections import defaultdict
from pathlib import Path
from typing import Literal, get_args
from .base import CLIResult, ToolError, ToolResult
from .run import maybe_truncate, run
Command = Literal[
'view',
'create',
'str_replace',
'insert',
'undo_edit',
]
SNIPPET_LINES: int = 4
class EditTool:
"""
An filesystem editor tool that allows the agent to view, create, and edit files.
The tool parameters are defined by Anthropic and are not editable.
Original implementation: https://github.com/anthropics/anthropic-quickstarts/blob/main/computer-use-demo/computer_use_demo/tools/edit.py
"""
_file_history: dict[Path, list[str]]
def __init__(self):
self._file_history = defaultdict(list)
super().__init__()
def __call__(
self,
*,
command: Command,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
**kwargs,
):
_path = Path(path)
self.validate_path(command, _path)
if command == 'view':
return self.view(_path, view_range)
elif command == 'create':
if file_text is None:
raise ToolError('Parameter `file_text` is required for command: create')
self.write_file(_path, file_text)
self._file_history[_path].append(file_text)
return ToolResult(output=f'File created successfully at: {_path}')
elif command == 'str_replace':
if old_str is None:
raise ToolError(
'Parameter `old_str` is required for command: str_replace'
)
return self.str_replace(_path, old_str, new_str)
elif command == 'insert':
if insert_line is None:
raise ToolError(
'Parameter `insert_line` is required for command: insert'
)
if new_str is None:
raise ToolError('Parameter `new_str` is required for command: insert')
return self.insert(_path, insert_line, new_str)
elif command == 'undo_edit':
return self.undo_edit(_path)
raise ToolError(
f'Unrecognized command {command}. The allowed commands for the {self.name} tool are: {", ".join(get_args(Command))}'
)
def validate_path(self, command: str, path: Path):
"""
Check that the path/command combination is valid.
"""
# Check if its an absolute path
if not path.is_absolute():
suggested_path = Path('') / path
raise ToolError(
f'The path {path} is not an absolute path, it should start with `/`. Maybe you meant {suggested_path}?'
)
# Check if path exists
if not path.exists() and command != 'create':
raise ToolError(
f'The path {path} does not exist. Please provide a valid path.'
)
if path.exists() and command == 'create':
raise ToolError(
f'File already exists at: {path}. Cannot overwrite files using command `create`.'
)
# Check if the path points to a directory
if path.is_dir():
if command != 'view':
raise ToolError(
f'The path {path} is a directory and only the `view` command can be used on directories'
)
def view(self, path: Path, view_range: list[int] | None = None):
"""Implement the view command"""
if path.is_dir():
if view_range:
raise ToolError(
'The `view_range` parameter is not allowed when `path` points to a directory.'
)
_, stdout, stderr = run(rf"find {path} -maxdepth 2 -not -path '*/\.*'")
if not stderr:
stdout = f"Here's the files and directories up to 2 levels deep in {path}, excluding hidden items:\n{stdout}\n"
return CLIResult(output=stdout, error=stderr)
file_content = self.read_file(path)
init_line = 1
if view_range:
if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
raise ToolError(
'Invalid `view_range`. It should be a list of two integers.'
)
file_lines = file_content.split('\n')
n_lines_file = len(file_lines)
init_line, final_line = view_range
if init_line < 1 or init_line > n_lines_file:
raise ToolError(
f"Invalid `view_range`: {view_range}. It's first element `{init_line}` should be within the range of lines of the file: {[1, n_lines_file]}"
)
if final_line > n_lines_file:
raise ToolError(
f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be smaller than the number of lines in the file: `{n_lines_file}`"
)
if final_line != -1 and final_line < init_line:
raise ToolError(
f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be larger or equal than its first `{init_line}`"
)
if final_line == -1:
file_content = '\n'.join(file_lines[init_line - 1 :])
else:
file_content = '\n'.join(file_lines[init_line - 1 : final_line])
return CLIResult(
output=self._make_output(file_content, str(path), init_line=init_line)
)
def str_replace(self, path: Path, old_str: str, new_str: str | None):
"""Implement the str_replace command, which replaces old_str with new_str in the file content"""
# Read the file content
file_content = self.read_file(path).expandtabs()
old_str = old_str.expandtabs()
new_str = new_str.expandtabs() if new_str is not None else ''
# Check if old_str is unique in the file
occurrences = file_content.count(old_str)
if occurrences == 0:
raise ToolError(
f'No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}.'
)
elif occurrences > 1:
file_content_lines = file_content.split('\n')
lines = [
idx + 1
for idx, line in enumerate(file_content_lines)
if old_str in line
]
raise ToolError(
f'No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines {lines}. Please ensure it is unique'
)
# Replace old_str with new_str
new_file_content = file_content.replace(old_str, new_str)
# Write the new content to the file
self.write_file(path, new_file_content)
# Save the content to history
self._file_history[path].append(file_content)
# Create a snippet of the edited section
replacement_line = file_content.split(old_str)[0].count('\n')
start_line = max(0, replacement_line - SNIPPET_LINES)
end_line = replacement_line + SNIPPET_LINES + new_str.count('\n')
snippet = '\n'.join(new_file_content.split('\n')[start_line : end_line + 1])
# Prepare the success message
success_msg = f'The file {path} has been edited. '
success_msg += self._make_output(
snippet, f'a snippet of {path}', start_line + 1
)
success_msg += 'Review the changes and make sure they are as expected. Edit the file again if necessary.'
return CLIResult(output=success_msg)
def insert(self, path: Path, insert_line: int, new_str: str):
"""Implement the insert command, which inserts new_str at the specified line in the file content."""
file_text = self.read_file(path).expandtabs()
new_str = new_str.expandtabs()
file_text_lines = file_text.split('\n')
n_lines_file = len(file_text_lines)
if insert_line < 0 or insert_line > n_lines_file:
raise ToolError(
f'Invalid `insert_line` parameter: {insert_line}. It should be within the range of lines of the file: {[0, n_lines_file]}'
)
new_str_lines = new_str.split('\n')
new_file_text_lines = (
file_text_lines[:insert_line]
+ new_str_lines
+ file_text_lines[insert_line:]
)
snippet_lines = (
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
+ new_str_lines
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
)
new_file_text = '\n'.join(new_file_text_lines)
snippet = '\n'.join(snippet_lines)
self.write_file(path, new_file_text)
self._file_history[path].append(file_text)
success_msg = f'The file {path} has been edited. '
success_msg += self._make_output(
snippet,
'a snippet of the edited file',
max(1, insert_line - SNIPPET_LINES + 1),
)
success_msg += 'Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary.'
return CLIResult(output=success_msg)
def undo_edit(self, path: Path):
"""Implement the undo_edit command."""
if not self._file_history[path]:
raise ToolError(f'No edit history found for {path}.')
old_text = self._file_history[path].pop()
self.write_file(path, old_text)
return CLIResult(
output=f'Last edit to {path} undone successfully. {self._make_output(old_text, str(path))}'
)
def read_file(self, path: Path):
"""Read the content of a file from a given path; raise a ToolError if an error occurs."""
try:
return path.read_text()
except Exception as e:
raise ToolError(f'Ran into {e} while trying to read {path}') from None
def write_file(self, path: Path, file: str):
"""Write the content of a file to a given path; raise a ToolError if an error occurs."""
try:
path.write_text(file)
except Exception as e:
raise ToolError(f'Ran into {e} while trying to write to {path}') from None
def _make_output(
self,
file_content: str,
file_descriptor: str,
init_line: int = 1,
expand_tabs: bool = True,
):
"""Generate output for the CLI based on the content of a file."""
file_content = maybe_truncate(file_content)
if expand_tabs:
file_content = file_content.expandtabs()
file_content = '\n'.join(
[
f'{i + init_line:6}\t{line}'
for i, line in enumerate(file_content.split('\n'))
]
)
return (
f"Here's the result of running `cat -n` on {file_descriptor}:\n"
+ file_content
+ '\n'
)

View File

@@ -1,44 +0,0 @@
"""Utility to run shell commands asynchronously with a timeout."""
import subprocess
import time
TRUNCATED_MESSAGE: str = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
MAX_RESPONSE_LEN: int = 16000
def maybe_truncate(content: str, truncate_after: int | None = MAX_RESPONSE_LEN):
"""Truncate content and append a notice if content exceeds the specified length."""
return (
content
if not truncate_after or len(content) <= truncate_after
else content[:truncate_after] + TRUNCATED_MESSAGE
)
def run(
cmd: str,
timeout: float | None = 120.0, # seconds
truncate_after: int | None = MAX_RESPONSE_LEN,
):
"""Run a shell command synchronously with a timeout."""
start_time = time.time()
try:
process = subprocess.Popen(
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
stdout, stderr = process.communicate(timeout=timeout)
return (
process.returncode or 0,
maybe_truncate(stdout, truncate_after=truncate_after),
maybe_truncate(stderr, truncate_after=truncate_after),
)
except subprocess.TimeoutExpired:
process.kill()
elapsed_time = time.time() - start_time
raise TimeoutError(
f"Command '{cmd}' timed out after {elapsed_time:.2f} seconds"
)

View File

@@ -1,10 +1,12 @@
import os
import httpx
from github import Github
from github.GithubException import GithubException
from tenacity import retry, stop_after_attempt, wait_exponential
from openhands.core.logger import openhands_logger as logger
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_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
@@ -12,7 +14,7 @@ GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
class UserVerifier:
def __init__(self) -> None:
logger.info('Initializing UserVerifier')
logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None
@@ -25,7 +27,7 @@ class UserVerifier:
"""Load users from text file if configured"""
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
if not waitlist:
logger.info('GITHUB_USER_LIST_FILE not configured')
logger.debug('GITHUB_USER_LIST_FILE not configured')
return
if not os.path.exists(waitlist):
@@ -46,10 +48,10 @@ class UserVerifier:
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id:
logger.info('GITHUB_USERS_SHEET_ID not configured')
logger.debug('GITHUB_USERS_SHEET_ID not configured')
return
logger.info('Initializing Google Sheets integration')
logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id
@@ -61,21 +63,21 @@ class UserVerifier:
if not self.is_active():
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 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
logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id:
sheet_users = self.sheets_client.get_usernames(self.spreadsheet_id)
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
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
@@ -83,10 +85,10 @@ async def authenticate_github_user(auth_token) -> bool:
user_verifier = UserVerifier()
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
logger.info('Checking GitHub token')
logger.debug('Checking GitHub token')
if not auth_token:
logger.warning('No GitHub token provided')
@@ -112,25 +114,14 @@ async def get_github_user(token: str) -> str:
Returns:
github handle of the user
"""
logger.info('Fetching GitHub user info from token')
headers = {
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {token}',
}
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.debug('Fetching GitHub user info from token')
try:
g = Github(token)
user = await call_sync_from_async(g.get_user)
login = user.login
logger.info(f'Successfully retrieved GitHub user: {login}')
return login
except GithubException as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise

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