Compare commits

...

27 Commits

Author SHA1 Message Date
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
Graham Neubig
090771674c Update llms.md w/ more recent results (#4874) 2024-11-10 03:12:09 +00:00
Xingyao Wang
d8ab0208ba fix: remove duplicate claude-3-5-sonnet-20241022 model from VERIFIED_MODELS (#4871)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-09 21:41:56 +00:00
Xingyao Wang
a07e8272da fix: improve remote runtime reliability on large-scale evaluation (#4869) 2024-11-09 20:17:10 +00:00
Robert Brennan
be82832eb1 Use keyword matching for CodeAct microagents (#4568)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2024-11-09 11:25:02 -05:00
119 changed files with 1438 additions and 1656 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

@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
## Model Recommendations
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed).
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and [this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate with the default agent in OpenHands.
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.

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

@@ -35,7 +35,8 @@ def codeact_user_response_eda(state: State) -> str:
# retrieve the latest model message from history
if state.history:
model_guess = state.get_last_agent_message()
last_agent_message = state.get_last_agent_message()
model_guess = last_agent_message.content if last_agent_message else ''
assert game is not None, 'Game is not initialized.'
msg = game.generate_user_response(model_guess)
@@ -140,7 +141,8 @@ def process_instance(
if state is None:
raise ValueError('State should not be None.')
final_message = state.get_last_agent_message()
last_agent_message = state.get_last_agent_message()
final_message = last_agent_message.content if last_agent_message else ''
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
test_result = game.reward()

View File

@@ -102,7 +102,8 @@ def process_instance(
raise ValueError('State should not be None.')
# retrieve the last message from the agent
model_answer_raw = state.get_last_agent_message()
last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
# attempt to parse model_answer
ast_eval_fn = instance['ast_eval']

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

@@ -83,6 +83,7 @@ def get_config(instance: pd.Series) -> AppConfig:
timeout=1800,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
),
# do not mount workspace
workspace_base=None,

View File

@@ -145,7 +145,8 @@ 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
workspace_base=None,

View File

@@ -127,7 +127,8 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
raise ValueError('State should not be None.')
# retrieve the last message from the agent
model_answer_raw = state.get_last_agent_message()
last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
# attempt to parse model_answer
correct = eval_answer(str(model_answer_raw), str(answer))

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

@@ -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,188 @@
import React from "react";
import {
useFetcher,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { ErrorObservation } from "#/types/core/observations";
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
import { handleAssistantMessage } from "#/services/actions";
import {
getCloneRepoCommand,
getGitHubTokenCommand,
} from "#/services/terminalService";
import {
clearFiles,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chatService";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { isGitHubErrorReponse } from "#/api/github";
import OpenHands from "#/api/open-hands";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
import { getSettings } from "#/services/settings";
interface ServerError {
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export function EventHandler({ children }: React.PropsWithChildren) {
const { events, status, send } = useWsClient();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const fetcher = useFetcher();
const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
const initialQueryRef = React.useRef<string | null>(
store.getState().initalQuery.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const userId = React.useMemo(() => {
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
return null;
}, [data?.user]);
const userSettings = getSettings();
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token) {
fetcher.submit({ token: event.token as string }, { method: "post" });
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
fetcher.submit({}, { method: "POST", action: "/end-session" });
return;
}
if (typeof event.error === "string") {
toast.error(event.error);
} else {
toast.error(event.message);
}
return;
}
if (isErrorObservation(event)) {
dispatch(
addErrorMessage({
id: event.extras?.error_id,
message: event.message,
}),
);
return;
}
handleAssistantMessage(event);
}, [events.length]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
const initialQuery = initialQueryRef.current;
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
if (ghToken && repo) {
send(getCloneRepoCommand(ghToken, repo));
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
else if (importedProjectZip) {
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
}
if (initialQuery) {
if (additionalInfo) {
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(initialQuery, files);
}
dispatch(clearFiles()); // reset selected files
initialQueryRef.current = null;
}
}
if (status === WsClientProviderStatus.OPENING && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
}),
);
}
if (status === WsClientProviderStatus.STOPPED) {
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
React.useEffect(() => {
if (runtimeActive && userId && ghToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(ghToken));
}
}, [userId, ghToken, runtimeActive]);
React.useEffect(() => {
(async () => {
if (runtimeActive && importedProjectZip) {
// upload files action
try {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
await OpenHands.uploadFiles([file]);
dispatch(setImportedProjectZip(null));
} catch (error) {
toast.error("Failed to upload project files.");
}
}
})();
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
if (userSettings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [userSettings.LLM_API_KEY]);
return children;
}

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);
@@ -43,10 +43,7 @@ export function ProjectMenuCard({
posthog.capture("push_to_github_button_clicked");
const rawEvent = {
content: `
Let's push the code to GitHub.
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
Commit any changes and push them to the remote repository.
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
Please push the changes to GitHub and open a pull request.
`,
imageUrls: [],
timestamp: new Date().toISOString(),

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);

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 {
@@ -202,14 +200,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 +303,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

@@ -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

@@ -1,10 +1,6 @@
// Here are the list of verified models and providers that we know work well with OpenHands.
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"];
export const VERIFIED_MODELS = [
"gpt-4o",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
];
export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
@@ -23,11 +19,9 @@ export const VERIFIED_OPENAI_MODELS = [
export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2",
"claude-2.1",
"claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-instant-1",
"claude-instant-1.2",
];

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

@@ -39,7 +39,6 @@ from openhands.runtime.plugins import (
JupyterRequirement,
PluginRequirement,
)
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager
@@ -86,16 +85,6 @@ class CodeActAgent(Agent):
super().__init__(llm, config)
self.reset()
self.micro_agent = (
MicroAgent(
os.path.join(
os.path.dirname(__file__), 'micro', f'{config.micro_agent_name}.md'
)
)
if config.micro_agent_name
else None
)
self.function_calling_active = self.config.function_calling
if self.function_calling_active and not self.llm.is_function_calling_active():
logger.warning(
@@ -105,7 +94,6 @@ class CodeActAgent(Agent):
self.function_calling_active = False
if self.function_calling_active:
# Function calling mode
self.tools = codeact_function_calling.get_tools(
codeact_enable_browsing=self.config.codeact_enable_browsing,
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
@@ -114,18 +102,19 @@ class CodeActAgent(Agent):
logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.system_prompt = codeact_function_calling.SYSTEM_PROMPT
self.initial_user_message = None
self.prompt_manager = PromptManager(
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:
# Non-function-calling mode
self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__)),
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,
micro_agent=self.micro_agent,
disabled_microagents=self.config.disabled_microagents,
)
self.system_prompt = self.prompt_manager.system_message
self.initial_user_message = self.prompt_manager.initial_user_message
self.pending_actions: deque[Action] = deque()
@@ -209,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,
@@ -337,8 +326,8 @@ class CodeActAgent(Agent):
return self.pending_actions.popleft()
# if we're done, go back
last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit':
latest_user_message = state.get_last_user_message()
if latest_user_message and latest_user_message.content.strip() == '/exit':
return AgentFinishAction()
# prepare what we want to send to the LLM
@@ -403,17 +392,19 @@ class CodeActAgent(Agent):
role='system',
content=[
TextContent(
text=self.system_prompt,
cache_prompt=self.llm.is_caching_prompt_active(), # Cache system prompt
text=self.prompt_manager.get_system_message(),
cache_prompt=self.llm.is_caching_prompt_active(),
)
],
)
]
if self.initial_user_message:
example_message = self.prompt_manager.get_example_user_message()
if example_message:
messages.append(
Message(
role='user',
content=[TextContent(text=self.initial_user_message)],
content=[TextContent(text=example_message)],
cache_prompt=self.llm.is_caching_prompt_active(),
)
)
@@ -462,8 +453,9 @@ class CodeActAgent(Agent):
pending_tool_call_action_messages.pop(response_id)
for message in messages_to_add:
# add regular message
if message:
if message.role == 'user':
self.prompt_manager.enhance_message(message)
# handle error if the message is the SAME role as the previous message
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
# there shouldn't be two consecutive messages from the same role
@@ -493,23 +485,6 @@ class CodeActAgent(Agent):
break
if not self.function_calling_active:
# The latest user message is important:
# we want to remind the agent of the environment constraints
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
)
# do not add this for function calling
if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
self.prompt_manager.add_turns_left_reminder(messages, state)
return messages

View File

@@ -25,14 +25,6 @@ from openhands.events.action import (
)
from openhands.events.tool import ToolCallMetadata
SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>
"""
_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.

View File

@@ -0,0 +1,9 @@
---
name: flarglebargle
agent: CodeActAgent
triggers:
- flarglebargle
---
IMPORTANT! The user has said the magic word "flarglebargle". You must
only respond with a message telling them how smart they are

View File

@@ -1,69 +1,31 @@
---
name: github
agent: CodeActAgent
require_env_var:
SANDBOX_ENV_GITHUB_TOKEN: "Create a GitHub Personal Access Token (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and set it as SANDBOX_GITHUB_TOKEN in your environment variables."
triggers:
- github
- git
---
# How to Interact with Github
You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
the GitHub API.
## Environment Variable Available
You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
ALWAYS use the GitHub API for operations instead of a web browser.
- `GITHUB_TOKEN`: A read-only token for Github.
## Using GitHub's RESTful API
Use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API. Here are some common operations:
Here's a template for API calls:
```sh
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/{endpoint}"
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch called `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitHub API to create a pull request, if you haven't already
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a pull request, send the user a short message with a link to the pull request.
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git checkout -b create-widget
git add .
git commit -m "Create widget"
git push origin create-widget
curl -X POST "https://api.github.com/repos/CodeActOrg/openhands/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
```
First replace `{endpoint}` with the specific API path. Common operations:
1. View an issue or pull request:
- Issues: `/repos/{owner}/{repo}/issues/{issue_number}`
- Pull requests: `/repos/{owner}/{repo}/pulls/{pull_request_number}`
2. List repository issues or pull requests:
- Issues: `/repos/{owner}/{repo}/issues`
- Pull requests: `/repos/{owner}/{repo}/pulls`
3. Search issues or pull requests:
- `/search/issues?q=repo:{owner}/{repo}+is:{type}+{search_term}+state:{state}`
- Replace `{type}` with `issue` or `pr`
4. List repository branches:
`/repos/{owner}/{repo}/branches`
5. Get commit details:
`/repos/{owner}/{repo}/commits/{commit_sha}`
6. Get repository details:
`/repos/{owner}/{repo}`
7. Get user information:
`/user`
8. Search repositories:
`/search/repositories?q={query}`
9. Get rate limit status:
`/rate_limit`
Replace `{owner}`, `{repo}`, `{commit_sha}`, `{issue_number}`, `{pull_request_number}`,
`{search_term}`, `{state}`, and `{query}` with appropriate values.
## Important Notes
1. Always use the GitHub API for operations instead of a web browser.
2. The `GITHUB_TOKEN` is read-only. Avoid operations that require write access.
3. Git config (username and email) is pre-set. Do not modify.
4. Edit and test code locally. Never push directly to remote.
5. Verify correct branch before committing.
6. Commit changes frequently.
7. If the issue or task is ambiguous or lacks sufficient detail, always request clarification from the user before proceeding.
8. You should avoid using command line tools like `sed` for file editing.

View File

@@ -215,12 +215,5 @@ The server is running on port 5000 with PID 126. You can access the list of numb
{% endset %}
Here is an example of how you can interact with the environment for task solving:
{{ DEFAULT_EXAMPLE }}
{% if micro_agent %}
--- BEGIN OF GUIDELINE ---
The following information may assist you in completing your task:
{{ micro_agent }}
--- END OF GUIDELINE ---
{% endif %}
NOW, LET'S START!

View File

@@ -0,0 +1,7 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>

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
@@ -155,7 +155,7 @@ class CodeActSWEAgent(Agent):
"""
# if we're done, go back
last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit':
if last_user_message and last_user_message.content.strip() == '/exit':
return AgentFinishAction()
# prepare what we want to send to the LLM

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,21 +149,21 @@ 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
return last_user_message, last_user_message_image_urls
def get_last_agent_message(self) -> str | None:
def get_last_agent_message(self) -> MessageAction | None:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
return event.content
return event
return None
def get_last_user_message(self) -> str | None:
def get_last_user_message(self) -> MessageAction | None:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event.content
return event
return 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

@@ -14,7 +14,8 @@ class SandboxConfig:
base_container_image: The base container image from which to build the runtime image.
runtime_container_image: The runtime container image to use.
user_id: The user ID for the sandbox.
timeout: The timeout for the sandbox.
timeout: The timeout for the default sandbox action execution.
remote_runtime_init_timeout: The timeout for the remote runtime to start.
enable_auto_lint: Whether to enable auto-lint.
use_host_network: Whether to use the host network.
initialize_plugins: Whether to initialize plugins.
@@ -35,12 +36,13 @@ 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
user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
timeout: int = 120
remote_runtime_init_timeout: int = 180
enable_auto_lint: bool = (
False # once enabled, OpenHands would lint files after editing
)

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

@@ -1,7 +1,7 @@
import os
from pathlib import Path
import tempfile
import threading
from pathlib import Path
from typing import Callable, Optional
from zipfile import ZipFile
@@ -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,
)
@@ -260,31 +265,34 @@ class RemoteRuntime(Runtime):
{'X-Session-API-Key': start_response['session_api_key']}
)
@tenacity.retry(
stop=tenacity.stop_after_delay(180) | stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
self.config.sandbox.remote_runtime_init_timeout
)
| stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
return retry_decorator(self._wait_until_alive_impl)()
def _wait_until_alive_impl(self):
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
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(
@@ -299,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',
@@ -313,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:
@@ -321,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,
)
@@ -336,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):
@@ -361,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,
@@ -374,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)
@@ -386,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
@@ -438,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,
@@ -461,6 +492,7 @@ class RemoteRuntime(Runtime):
response = self._send_request(
'POST',
f'{self.runtime_url}/list_files',
is_retry=False,
json=data,
timeout=30,
)
@@ -474,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},
)

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