Compare commits

..

1 Commits

Author SHA1 Message Date
openhands
a17d06691a Fix issue #4819: [Bug]: Logs are not printed out in headless command line mode 2024-11-07 14:02:18 +00:00
72 changed files with 4339 additions and 4829 deletions

View File

@@ -11,5 +11,5 @@ jobs:
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
if: github.event.label.name == 'fix-me'
with:
max_iterations: 50
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }}
secrets: inherit

View File

@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.12-nikolaik
## Develop inside Docker container

View File

@@ -38,15 +38,15 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker run -it --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13
docker.all-hands.dev/all-hands-ai/openhands:0.12
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -32,8 +32,7 @@ workspace_base = "./workspace"
# Enable saving and restoring the session when run from CLI
#enable_cli_session = false
# Path to store trajectories, can be a folder or a file
# If it's a folder, the session id will be used as the file name
# Path to store trajectories
#trajectories_path="./trajectories"
# File store path

View File

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

View File

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

View File

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

View File

@@ -11,15 +11,15 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13
docker.all-hands.dev/all-hands-ai/openhands:0.12
```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
## Model Recommendations
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).
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).
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 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate 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

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

View File

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

View File

@@ -83,7 +83,6 @@ 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

@@ -146,7 +146,6 @@ def get_config(
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,
remote_runtime_init_timeout=1800,
),
# do not mount workspace
workspace_base=None,

View File

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

View File

@@ -1,5 +1,5 @@
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/chat-input";
@@ -158,46 +158,4 @@ describe("ChatInput", () => {
await user.tab();
expect(onBlurMock).toHaveBeenCalledOnce();
});
it("should handle text paste correctly", () => {
const onSubmit = vi.fn();
const onChange = vi.fn();
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Fire paste event with text data
fireEvent.paste(input!, {
clipboardData: {
getData: (type: string) => type === 'text/plain' ? 'test paste' : '',
files: []
}
});
});
it("should handle image paste correctly", () => {
const onSubmit = vi.fn();
const onImagePaste = vi.fn();
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Create a paste event with an image file
const file = new File(["dummy content"], "image.png", { type: "image/png" });
// Fire paste event with image data
fireEvent.paste(input!, {
clipboardData: {
getData: () => '',
files: [file]
}
});
// Verify image paste was handled
expect(onImagePaste).toHaveBeenCalledWith([file]);
});
});

View File

@@ -1,53 +0,0 @@
import { afterEach } from "node:test";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cache } from "#/utils/cache";
describe("Cache", () => {
const testKey = "key";
const testData = { message: "Hello, world!" };
const testTTL = 1000; // 1 second
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("gets data from memory if not expired", () => {
cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData);
});
it("should expire after 5 minutes by default", () => {
cache.set(testKey, testData);
expect(cache.get(testKey)).not.toBeNull();
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(cache.get(testKey)).toBeNull();
});
it("returns null if cached data is expired", () => {
cache.set(testKey, testData, testTTL);
vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull();
});
it("deletes data from memory", () => {
cache.set(testKey, testData, testTTL);
cache.delete(testKey);
expect(cache.get(testKey)).toBeNull();
});
it("clears all data with the app prefix from memory", () => {
cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll();
expect(cache.get(testKey)).toBeNull();
expect(cache.get("anotherKey")).toBeNull();
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
@@ -63,7 +63,6 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -7924,24 +7923,6 @@
}
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"private": true,
"type": "module",
"engines": {
@@ -45,8 +45,8 @@
"ws": "^8.18.0"
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true remix vite:dev",
"dev": "npm run make-i18n && VITE_MOCK_API=false remix vite:dev",
"dev:mock": "npm run make-i18n && VITE_MOCK_API=true remix vite:dev",
"build": "npm run make-i18n && tsc && remix vite:build",
"start": "npx sirv-cli build/ --single",
"test": "vitest run",
@@ -89,7 +89,6 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",

View File

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

View File

@@ -139,6 +139,33 @@ export const retrieveGitHubUser = async (
return error;
};
/**
* Given a GitHub token and a repository name, creates a repository for the authenticated user
* @param token The GitHub token
* @param repositoryName Name of the repository to create
* @param description Description of the repository
* @param isPrivate Boolean indicating if the repository should be private
* @returns The created repository or an error response
*/
export const createGitHubRepository = async (
token: string,
repositoryName: string,
description?: string,
isPrivate = true,
): Promise<GitHubRepository | GitHubErrorReponse> => {
const response = await fetch("https://api.github.com/user/repos", {
method: "POST",
headers: generateGitHubAPIHeaders(token),
body: JSON.stringify({
name: repositoryName,
description,
private: isPrivate,
}),
});
return response.json();
};
export const retrieveLatestGitHubCommit = async (
token: string,
repository: string,

View File

@@ -1,5 +1,4 @@
import { request } from "#/services/api";
import { cache } from "#/utils/cache";
import {
SaveFileSuccessResponse,
FileUploadSuccessResponse,
@@ -16,13 +15,7 @@ class OpenHands {
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
const cachedData = cache.get<string[]>("models");
if (cachedData) return cachedData;
const data = await request("/api/options/models");
cache.set("models", data);
return data;
return request("/api/options/models");
}
/**
@@ -30,13 +23,7 @@ class OpenHands {
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
const cachedData = cache.get<string[]>("agents");
if (cachedData) return cachedData;
const data = await request(`/api/options/agents`);
cache.set("agents", data);
return data;
return request(`/api/options/agents`);
}
/**
@@ -44,23 +31,11 @@ class OpenHands {
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
const cachedData = cache.get<string[]>("agents");
if (cachedData) return cachedData;
const data = await request(`/api/options/security-analyzers`);
cache.set("security-analyzers", data);
return data;
return request(`/api/options/security-analyzers`);
}
static async getConfig(): Promise<GetConfigResponse> {
const cachedData = cache.get<GetConfigResponse>("config");
if (cachedData) return cachedData;
const data = await request("/config.json");
cache.set("config", data);
return data;
return request("/config.json");
}
/**

View File

@@ -25,11 +25,7 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
<SyntaxHighlighter
language="python"
style={atomOneDark}
wrapLongLines
>
<SyntaxHighlighter language="python" style={atomOneDark}>
{code}
</SyntaxHighlighter>
</pre>
@@ -82,11 +78,7 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
);
}
interface JupyterEditorProps {
maxWidth: number;
}
function JupyterEditor({ maxWidth }: JupyterEditorProps) {
function JupyterEditor(): JSX.Element {
const { t } = useTranslation();
const { cells } = useSelector((state: RootState) => state.jupyter);
@@ -96,7 +88,7 @@ function JupyterEditor({ maxWidth }: JupyterEditorProps) {
useScrollToBottom(jupyterRef);
return (
<div className="flex-1" style={{ maxWidth }}>
<div className="flex-1">
<div
className="overflow-y-auto h-full"
ref={jupyterRef}

View File

@@ -40,18 +40,13 @@ export function ChatInput({
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Only handle paste if we have an image paste handler and there are files
event.preventDefault();
if (onImagePaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files).filter((file) =>
file.type.startsWith("image/"),
);
// Only prevent default if we found image files to handle
if (files.length > 0) {
event.preventDefault();
onImagePaste(files);
}
if (files.length > 0) onImagePaste(files);
}
// For text paste, let the default behavior handle it
};
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {

View File

@@ -1,6 +1,5 @@
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";
@@ -44,9 +43,6 @@ export function ChatInterface() {
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const handleSendMessage = async (content: string, files: File[]) => {
posthog.capture("user_message_sent", {
current_message_count: messages.length,
});
const promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises);
@@ -57,7 +53,6 @@ export function ChatInterface() {
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};

View File

@@ -1,7 +1,6 @@
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 { ModalBackdrop } from "../modals/modal-backdrop";
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
@@ -12,7 +11,6 @@ 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";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -33,17 +31,18 @@ export function ProjectMenuCard({
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [working, setWorking] = React.useState(false);
const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev);
};
const handlePushToGitHub = () => {
posthog.capture("push_to_github_button_clicked");
const rawEvent = {
content: `
Please push the changes to GitHub and open a pull request.
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.
`,
imageUrls: [],
timestamp: new Date().toISOString(),
@@ -59,27 +58,20 @@ Please push the changes to GitHub and open a pull request.
setContextMenuIsOpen(false);
};
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
try {
setWorking(true);
downloadWorkspace().then(
() => setWorking(false),
() => setWorking(false),
);
} catch (error) {
toast.error("Failed to download workspace");
}
};
return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{!working && contextMenuIsOpen && (
{contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
onPushToGitHub={handlePushToGitHub}
onDownloadWorkspace={handleDownloadWorkspace}
onDownloadWorkspace={() => {
try {
downloadWorkspace();
} catch (error) {
toast.error("Failed to download workspace");
}
}}
onClose={() => setContextMenuIsOpen(false)}
/>
)}
@@ -101,11 +93,7 @@ Please push the changes to GitHub and open a pull request.
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
{working ? (
<LoadingSpinner size="small" />
) : (
<EllipsisH width={36} height={36} />
)}
<EllipsisH width={36} height={36} />
</button>
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>

View File

@@ -1,8 +1,6 @@
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardContextMenuProps {
isConnectedToGitHub: boolean;
@@ -20,7 +18,7 @@ export function ProjectMenuCardContextMenu({
onClose,
}: ProjectMenuCardContextMenuProps) {
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
return (
<ContextMenu
ref={menuRef}
@@ -28,16 +26,16 @@ export function ProjectMenuCardContextMenu({
>
{!isConnectedToGitHub && (
<ContextMenuListItem onClick={onConnectToGitHub}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
Connect to GitHub
</ContextMenuListItem>
)}
{isConnectedToGitHub && (
<ContextMenuListItem onClick={onPushToGitHub}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
Push to GitHub
</ContextMenuListItem>
)}
<ContextMenuListItem onClick={onDownloadWorkspace}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
Download as .zip
</ContextMenuListItem>
</ContextMenu>
);

View File

@@ -1,6 +1,5 @@
import React from "react";
import { Data } from "ws";
import posthog from "posthog-js";
import EventLogger from "#/utils/event-logger";
interface WebSocketClientOptions {
@@ -59,7 +58,6 @@ function SocketProvider({ children }: SocketProviderProps) {
]);
ws.addEventListener("open", (event) => {
posthog.capture("socket_opened");
setIsConnected(true);
options?.onOpen?.(event);
});
@@ -72,13 +70,11 @@ function SocketProvider({ children }: SocketProviderProps) {
});
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);

View File

@@ -101,10 +101,6 @@ export const useTerminal = (
if (commandBuffer.length > 0) {
commandBuffer = handleBackspace(commandBuffer);
}
} else if (domEvent.key === "Tab") {
// Swallow tab key and convert to space
commandBuffer += " ";
terminal.current?.write(" ");
} else {
// Ignore paste event
if (key.charCodeAt(0) === 22) {

View File

@@ -1531,12 +1531,10 @@
"tr": "Özel"
},
"ERROR_MESSAGE$SHOW_DETAILS": {
"en": "Show details",
"es": "Mostrar detalles"
"en": "Show details"
},
"ERROR_MESSAGE$HIDE_DETAILS": {
"en": "Hide details",
"es": "Ocultar detalles"
"en": "Hide details"
},
"STATUS$STARTING_RUNTIME": {
"en": "Starting Runtime...",
@@ -1618,7 +1616,7 @@
},
"ACCOUNT_SETTINGS_MODAL$CLOSE":{
"en": "Close",
"es": "Cerrar"
"es": ""
},
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
"en": "GitHub token is invalid. Please try again.",
@@ -1737,8 +1735,7 @@
"es":"atrás"
},
"STATUS$ERROR_LLM_AUTHENTICATION": {
"en": "Error authenticating with the LLM provider. Please check your API key",
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
"en": "Error authenticating with the LLM provider. Please check your API key"
},
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
"en": "There was an error while connecting to the runtime. Please refresh the page."
@@ -1748,17 +1745,5 @@
},
"AGENT_ERROR$ACTION_TIMEOUT": {
"en": "Action timed out."
},
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
"en": "Connect to GitHub",
"es": "Conectar a GitHub"
},
"PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL": {
"en": "Push to GitHub",
"es": "Subir a GitHub"
},
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
"en": "Download as .zip",
"es": "Descargar como .zip"
}
}

View File

@@ -10,7 +10,6 @@ import {
} from "@remix-run/react";
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading";
@@ -26,6 +25,9 @@ import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
let cachedRepositories: ReturnType<
typeof retrieveAllGitHubUserRepositories
> | null = null;
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let isSaas = false;
let githubClientId: string | null = null;
@@ -46,9 +48,12 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let repositories: ReturnType<
typeof retrieveAllGitHubUserRepositories
> | null = null;
if (ghToken) {
if (cachedRepositories) {
repositories = cachedRepositories;
} else if (ghToken) {
const data = retrieveAllGitHubUserRepositories(ghToken);
repositories = data;
cachedRepositories = data;
}
let githubAuthUrl: string | null = null;
@@ -65,10 +70,6 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const q = formData.get("q")?.toString();
if (q) store.dispatch(setInitialQuery(q));
posthog.capture("initial_query_submitted", {
query_character_length: q?.length,
});
return redirect("/app");
};

View File

@@ -1,23 +1,7 @@
import React from "react";
import JupyterEditor from "#/components/Jupyter";
function Jupyter() {
const parentRef = React.useRef<HTMLDivElement>(null);
const [parentWidth, setParentWidth] = React.useState(0);
// This is a hack to prevent the editor from overflowing
// Should be removed after revising the parent and containers
React.useEffect(() => {
if (parentRef.current) {
setParentWidth(parentRef.current.offsetWidth);
}
}, []);
return (
<div ref={parentRef}>
<JupyterEditor maxWidth={parentWidth} />
</div>
);
return <JupyterEditor />;
}
export default Jupyter;

View File

@@ -49,6 +49,7 @@ import { clearJupyter } from "#/state/jupyterSlice";
import { FilesProvider } from "#/context/files";
import { ErrorObservation } from "#/types/core/observations";
import { ChatInterface } from "#/components/chat-interface";
import { cn } from "#/utils/utils";
interface ServerError {
error: boolean | string;
@@ -68,6 +69,8 @@ const isAgentStateChange = (
data.extras instanceof Object &&
"agent_state" in data.extras;
let lastCommitCached: GitHubCommit | null = null;
let repoForLastCommit: string | null = null;
export const clientLoader = async () => {
const ghToken = localStorage.getItem("ghToken");
@@ -81,14 +84,16 @@ export const clientLoader = async () => {
if (repo) localStorage.setItem("repo", repo);
let lastCommit: GitHubCommit | null = null;
if (ghToken && repo) {
const data = await retrieveLatestGitHubCommit(ghToken, repo);
if (isGitHubErrorReponse(data)) {
// TODO: Handle error
console.error("Failed to retrieve latest commit", data);
} else {
[lastCommit] = data;
if (!lastCommitCached || repoForLastCommit !== repo) {
if (ghToken && repo) {
const data = await retrieveLatestGitHubCommit(ghToken, repo);
if (isGitHubErrorReponse(data)) {
// TODO: Handle error
console.error("Failed to retrieve latest commit", data);
} else {
[lastCommitCached] = data;
repoForLastCommit = repo;
}
}
}
@@ -98,7 +103,7 @@ export const clientLoader = async () => {
ghToken,
repo,
q,
lastCommit,
lastCommit: lastCommitCached,
});
};
@@ -294,6 +299,15 @@ function App() {
<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">
<div
className={cn(
"w-2 h-2 rounded-full border",
"absolute left-3 top-3",
runtimeActive
? "bg-green-800 border-green-500"
: "bg-red-800 border-red-500",
)}
/>
<ChatInterface />
</Container>

View File

@@ -34,6 +34,9 @@ import { AnalyticsConsentFormModal } from "#/components/analytics-consent-form-m
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
// Cache for clientLoader results
let clientLoaderCache: any = null; // eslint-disable-line @typescript-eslint/no-explicit-any
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
try {
const config = await OpenHands.getConfig();
@@ -58,21 +61,27 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let isAuthed = false;
let githubAuthUrl: string | null = null;
let user: GitHubUser | GitHubErrorReponse | null = null;
try {
isAuthed = await userIsAuthenticated();
if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
const requestUrl = new URL(request.url);
githubAuthUrl = generateGitHubAuthUrl(
window.__GITHUB_CLIENT_ID__,
requestUrl,
);
if (!clientLoaderCache || clientLoaderCache.ghToken !== ghToken) {
try {
isAuthed = await userIsAuthenticated();
if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
const requestUrl = new URL(request.url);
githubAuthUrl = generateGitHubAuthUrl(
window.__GITHUB_CLIENT_ID__,
requestUrl,
);
}
} catch (error) {
isAuthed = false;
githubAuthUrl = null;
}
} catch (error) {
isAuthed = false;
githubAuthUrl = null;
}
if (ghToken) user = await retrieveGitHubUser(ghToken);
if (ghToken) user = await retrieveGitHubUser(ghToken);
} else {
isAuthed = clientLoaderCache.isAuthed;
githubAuthUrl = clientLoaderCache.githubAuthUrl;
user = clientLoaderCache.user;
}
const settings = getSettings();
await i18n.changeLanguage(settings.LANGUAGE);
@@ -84,7 +93,7 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
}
// Store the results in cache
return defer({
clientLoaderCache = {
token,
ghToken,
isAuthed,
@@ -93,7 +102,9 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
settingsIsUpdated,
settings,
analyticsConsent,
});
};
return defer(clientLoaderCache);
};
export function ErrorBoundary() {

View File

@@ -1,12 +1,10 @@
import { json } from "@remix-run/react";
import posthog from "posthog-js";
import { cache } from "#/utils/cache";
export const clientAction = () => {
const ghToken = localStorage.getItem("ghToken");
if (ghToken) localStorage.removeItem("ghToken");
cache.clearAll();
posthog.reset();
return json({ success: true });

View File

@@ -1,5 +1,4 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react";
import posthog from "posthog-js";
import {
getDefaultSettings,
LATEST_SETTINGS_VERSION,
@@ -39,7 +38,6 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
saveSettings(getDefaultSettings());
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_reset");
return json({ success: true });
}
@@ -99,10 +97,5 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
}
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_saved", {
LLM_MODEL,
LLM_API_KEY: LLM_API_KEY ? "SET" : "UNSET",
});
return json({ success: true });
};

View File

@@ -63,16 +63,6 @@ export async function request(
} catch (e) {
onFail(`Error fetching ${url}`);
}
if (response?.status === 401) {
await request(
"/api/authenticate",
{
method: "POST",
},
true,
);
return request(url, options, disableToast, returnResponse, maxRetries - 1);
}
if (response?.status && response?.status >= 400) {
onFail(
`${response.status} error while fetching ${url}: ${response?.statusText}`,

View File

@@ -1,61 +0,0 @@
type CacheKey = string;
type CacheEntry<T> = {
data: T;
expiration: number;
};
class Cache {
private defaultTTL = 5 * 60 * 1000; // 5 minutes
private cacheMemory: Record<string, string> = {};
/**
* Retrieve the cached data from memory
* @param key The key to be retrieved from memory
* @returns The data stored in memory
*/
public get<T>(key: CacheKey): T | null {
const cachedEntry = this.cacheMemory[key];
if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data;
this.delete(key); // Remove expired cache
}
return null;
}
/**
* Store the data in memory with expiration
* @param key The key to be stored in memory
* @param data The data to be stored in memory
* @param ttl The time to live for the data in milliseconds
* @returns void
*/
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
const expiration = Date.now() + ttl;
const entry: CacheEntry<T> = { data, expiration };
this.cacheMemory[key] = JSON.stringify(entry);
}
/**
* Remove the data from memory
* @param key The key to be removed from memory
* @returns void
*/
public delete(key: CacheKey): void {
delete this.cacheMemory[key];
}
/**
* Clear all data
* @returns void
*/
public clearAll(): void {
Object.keys(this.cacheMemory).forEach((key) => {
delete this.cacheMemory[key];
});
}
}
export const cache = new Cache();

View File

@@ -1,20 +1,12 @@
import OpenHands from "#/api/open-hands";
import { cache } from "./cache";
export const userIsAuthenticated = async () => {
if (window.__APP_MODE__ === "oss") return true;
const cachedData = cache.get<boolean>("user_is_authenticated");
if (cachedData) return cachedData;
let authenticated = false;
try {
await OpenHands.authenticate();
authenticated = true;
return true;
} catch (error) {
authenticated = false;
return false;
}
cache.set("user_is_authenticated", authenticated, 3 * 60 * 1000); // cache for 3 minutes
return authenticated;
};

View File

@@ -1,6 +1,10 @@
// 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-20241022"];
export const VERIFIED_MODELS = [
"gpt-4o",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
@@ -19,8 +23,11 @@ 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-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-instant-1",
"claude-instant-1.2",
];

View File

@@ -39,6 +39,7 @@ from openhands.runtime.plugins import (
JupyterRequirement,
PluginRequirement,
)
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager
@@ -85,6 +86,16 @@ 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(
@@ -94,6 +105,7 @@ 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,
@@ -102,17 +114,18 @@ class CodeActAgent(Agent):
logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
)
self.system_prompt = codeact_function_calling.SYSTEM_PROMPT
self.initial_user_message = None
else:
# Non-function-calling mode
self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
prompt_dir=os.path.join(os.path.dirname(__file__)),
agent_skills_docs=AgentSkillsRequirement.documentation,
micro_agent=self.micro_agent,
)
self.system_prompt = self.prompt_manager.system_message
self.initial_user_message = self.prompt_manager.initial_user_message
self.pending_actions: deque[Action] = deque()
@@ -324,8 +337,8 @@ class CodeActAgent(Agent):
return self.pending_actions.popleft()
# if we're done, go back
latest_user_message = state.get_last_user_message()
if latest_user_message and latest_user_message.content.strip() == '/exit':
last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit':
return AgentFinishAction()
# prepare what we want to send to the LLM
@@ -390,19 +403,17 @@ class CodeActAgent(Agent):
role='system',
content=[
TextContent(
text=self.prompt_manager.get_system_message(),
cache_prompt=self.llm.is_caching_prompt_active(),
text=self.system_prompt,
cache_prompt=self.llm.is_caching_prompt_active(), # Cache system prompt
)
],
)
]
example_message = self.prompt_manager.get_example_user_message()
if example_message:
if self.initial_user_message:
messages.append(
Message(
role='user',
content=[TextContent(text=example_message)],
cache_prompt=self.llm.is_caching_prompt_active(),
content=[TextContent(text=self.initial_user_message)],
)
)
@@ -451,9 +462,8 @@ 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
@@ -483,6 +493,23 @@ class CodeActAgent(Agent):
break
if not self.function_calling_active:
self.prompt_manager.add_turns_left_reminder(messages, state)
# 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))
return messages

View File

@@ -25,6 +25,13 @@ 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.
</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.
@@ -277,17 +284,6 @@ _browser_action_space = HighLevelActionSet(
_BROWSER_DESCRIPTION = """Interact with the browser using Python code.
See the description of "code" parameter for more details.
Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
fill('a12', 'example with "quotes"')
click('a51')
click('48', button='middle', modifiers=['Shift'])
"""
_BROWSER_TOOL_DESCRIPTION = """
The following 15 functions are available. Nothing else is supported.
goto(url: str)
@@ -389,15 +385,20 @@ upload_file(bid: str, file: str | list[str])
upload_file('572', '/home/user/my_receipt.pdf')
upload_file('63', ['/home/bob/Documents/image.jpg', '/home/bob/Documents/file.zip'])
"""
Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
fill('a12', 'example with "quotes"')
click('a51')
click('48', button='middle', modifiers=['Shift'])
"""
for _, action in _browser_action_space.action_set.items():
assert (
action.signature in _BROWSER_TOOL_DESCRIPTION
action.signature in _BROWSER_DESCRIPTION
), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.signature}'
assert (
action.description in _BROWSER_TOOL_DESCRIPTION
action.description in _BROWSER_DESCRIPTION
), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.description}'
BrowserTool = ChatCompletionToolParam(
@@ -410,10 +411,7 @@ BrowserTool = ChatCompletionToolParam(
'properties': {
'code': {
'type': 'string',
'description': (
'The Python code that interacts with the browser.\n'
+ _BROWSER_TOOL_DESCRIPTION
),
'description': 'The Python code that interacts with the browser.',
}
},
'required': ['code'],

View File

@@ -1,9 +0,0 @@
---
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,31 +1,69 @@
---
name: github
agent: CodeActAgent
triggers:
- github
- git
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."
---
You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
the GitHub API.
# How to Interact with Github
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.
## Environment Variable Available
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"}'
- `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}"
```
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

@@ -1,7 +0,0 @@
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

@@ -163,9 +163,6 @@ IMPORTANT: Execute code using <execute_ipython>, <execute_bash>, or <execute_bro
The assistant should utilize full file paths and the `pwd` command to prevent path-related errors.
The assistant MUST NOT apologize to the user or thank the user after running commands or editing files. It should only address the user in response to an explicit message from the user, or to ask for more information.
The assistant MUST NOT push any changes to GitHub unless explicitly requested to do so.
The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior, or
to describe precisely how to apply proposed edits. Comments about applying edits should always have blank lines above
and below.
{% endset %}
{# Combine all parts without newlines between them #}

View File

@@ -215,5 +215,12 @@ 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

@@ -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.content.strip() == '/exit':
if last_user_message and last_user_message.strip() == '/exit':
return AgentFinishAction()
# prepare what we want to send to the LLM

View File

@@ -156,14 +156,14 @@ class State:
return last_user_message, last_user_message_image_urls
def get_last_agent_message(self) -> MessageAction | None:
def get_last_agent_message(self) -> str | None:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
return event
return event.content
return None
def get_last_user_message(self) -> MessageAction | None:
def get_last_user_message(self) -> str | None:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event
return event.content
return None

View File

@@ -69,7 +69,6 @@ class AppConfig:
file_uploads_max_file_size_mb: int = 0
file_uploads_restrict_file_types: bool = False
file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
runloop_api_key: str | None = None
defaults_dict: ClassVar[dict] = {}
@@ -140,7 +139,6 @@ class AppConfig:
'jwt_secret',
'modal_api_token_id',
'modal_api_token_secret',
'runloop_api_key',
]:
attr_value = '******' if attr_value else None

View File

@@ -14,8 +14,7 @@ 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 default sandbox action execution.
remote_runtime_init_timeout: The timeout for the remote runtime to start.
timeout: The timeout for the sandbox.
enable_auto_lint: Whether to enable auto-lint.
use_host_network: Whether to use the host network.
initialize_plugins: Whether to initialize plugins.
@@ -42,7 +41,6 @@ class SandboxConfig:
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

@@ -123,7 +123,6 @@ async def run_controller(
if runtime is None:
runtime = create_runtime(config, sid=sid)
await runtime.connect()
event_stream = runtime.event_stream
@@ -186,9 +185,14 @@ async def run_controller(
message = fake_user_response_fn(controller.get_state())
action = MessageAction(content=message)
event_stream.add_event(action, EventSource.USER)
elif headless_mode:
# Log all events in headless mode
logger.info(event)
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
await runtime.connect()
end_states = [
AgentState.FINISHED,
AgentState.REJECTED,
@@ -212,11 +216,7 @@ async def run_controller(
# save trajectories if applicable
if config.trajectories_path is not None:
# if trajectories_path is a folder, use session id as file name
if os.path.isdir(config.trajectories_path):
file_path = os.path.join(config.trajectories_path, sid + '.json')
else:
file_path = config.trajectories_path
file_path = os.path.join(config.trajectories_path, sid + '.json')
os.makedirs(os.path.dirname(file_path), exist_ok=True)
histories = [event_to_trajectory(event) for event in state.history]
with open(file_path, 'w') as f:

View File

@@ -23,10 +23,6 @@ def get_runtime_cls(name: str):
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
return ModalRuntime
elif name == 'runloop':
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
return RunloopRuntime
else:
raise ValueError(f'Runtime {name} not supported')

View File

@@ -3,7 +3,6 @@ import copy
import json
import os
from abc import abstractmethod
from pathlib import Path
from typing import Callable
from requests.exceptions import ConnectionError
@@ -275,6 +274,6 @@ class Runtime(FileEditRuntimeMixin):
raise NotImplementedError('This method is not implemented in the base class.')
@abstractmethod
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return a path in the local filesystem."""
def copy_from(self, path: str) -> bytes:
"""Zip all files in the sandbox and return as a stream of bytes."""
raise NotImplementedError('This method is not implemented in the base class.')

View File

@@ -1,5 +1,4 @@
import os
from pathlib import Path
import tempfile
import threading
from functools import lru_cache
@@ -605,7 +604,7 @@ class EventStreamRuntime(Runtime):
except requests.Timeout:
raise TimeoutError('List files operation timed out')
def copy_from(self, path: str) -> Path:
def copy_from(self, path: str) -> bytes:
"""Zip all files in the sandbox and return as a stream of bytes."""
self._refresh_logs()
try:
@@ -618,11 +617,8 @@ class EventStreamRuntime(Runtime):
stream=True,
timeout=30,
)
temp_file = tempfile.NamedTemporaryFile(delete=False)
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
temp_file.write(chunk)
return Path(temp_file.name)
data = response.content
return data
except requests.Timeout:
raise TimeoutError('Copy operation timed out')

View File

@@ -1,7 +1,6 @@
import os
import tempfile
import threading
from pathlib import Path
from typing import Callable, Optional
from zipfile import ZipFile
@@ -91,8 +90,9 @@ class RemoteRuntime(Runtime):
self.runtime_url: str | None = None
async def connect(self):
await call_sync_from_async(self._start_or_attach_to_runtime)
try:
await call_sync_from_async(self._start_or_attach_to_runtime)
await call_sync_from_async(self._wait_until_alive)
except RuntimeNotReadyError:
self.log('error', 'Runtime failed to start, timed out before ready')
raise
@@ -260,19 +260,13 @@ 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',
@@ -283,14 +277,6 @@ class RemoteRuntime(Runtime):
assert runtime_data['runtime_id'] == self.runtime_id
assert 'pod_status' in runtime_data
pod_status = runtime_data['pod_status']
# FIXME: We should fix it at the backend of /start endpoint, make sure
# the pod is created before returning the response.
# Retry a period of time to give the cluster time to start the pod
if pod_status == 'Not Found':
raise RuntimeNotReadyError(
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
)
if pod_status == 'Ready':
try:
self._send_request(
@@ -305,7 +291,7 @@ class RemoteRuntime(Runtime):
f'Runtime /alive failed to respond with 200: {e}'
)
return
if pod_status in ('Failed', 'Unknown'):
if pod_status in ('Failed', 'Unknown', 'Not Found'):
# clean up the runtime
self.close()
raise RuntimeError(
@@ -474,18 +460,13 @@ class RemoteRuntime(Runtime):
assert isinstance(response_json, list)
return response_json
def copy_from(self, path: str) -> Path:
def copy_from(self, path: str) -> bytes:
"""Zip all files in the sandbox and return as a stream of bytes."""
params = {'path': path}
response = self._send_request(
'GET',
f'{self.runtime_url}/download_files',
params=params,
stream=True,
timeout=30,
)
temp_file = tempfile.NamedTemporaryFile(delete=False)
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
temp_file.write(chunk)
return Path(temp_file.name)
return response.content

View File

@@ -1,31 +0,0 @@
# Runloop Runtime
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
for more detail
## Access
Runloop is currently available in a closed beta. For early access, or
just to say hello, sign up at https://www.runloop.ai/hello
## Set up
With your runloop API,
```bash
export RUNLOOP_API_KEY=<your-api-key>
```
Configure the runtime
```bash
export RUNTIME="runloop"
```
## Interact with your devbox
Runloop provides additional tools to interact with your Devbox based
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
to date list of tools.
### Dashboard
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
### CLI
Use the Runloop CLI to view logs, execute commands, and more.
See the setup instructions [here](https://docs.runloop.ai/tools/cli)

View File

@@ -1,272 +0,0 @@
import logging
import threading
import time
from typing import Callable
import requests
import tenacity
from runloop_api_client import Runloop
from runloop_api_client.types import DevboxView
from runloop_api_client.types.shared_params import LaunchParameters
from openhands.core.config import AppConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.runtime.impl.eventstream.eventstream_runtime import (
EventStreamRuntime,
LogBuffer,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.request import send_request
from openhands.utils.tenacity_stop import stop_if_should_exit
class RunloopLogBuffer(LogBuffer):
"""Synchronous buffer for Runloop devbox logs.
This class provides a thread-safe way to collect, store, and retrieve logs
from a Docker container. It uses a list to store log lines and provides methods
for appending, retrieving, and clearing logs.
"""
def __init__(self, runloop_api_client: Runloop, devbox_id: str):
self.client_ready = False
self.init_msg = 'Runtime client initialized.'
self.buffer: list[str] = []
self.lock = threading.Lock()
self._stop_event = threading.Event()
self.runloop_api_client = runloop_api_client
self.devbox_id = devbox_id
self.log_index = 0
self.log_stream_thread = threading.Thread(target=self.stream_logs)
self.log_stream_thread.daemon = True
self.log_stream_thread.start()
def stream_logs(self):
"""Stream logs from the Docker container in a separate thread.
This method runs in its own thread to handle the blocking
operation of reading log lines from the Docker SDK's synchronous generator.
"""
try:
# TODO(Runloop) Replace with stream
while True:
raw_logs = self.runloop_api_client.devboxes.logs.list(
self.devbox_id
).logs[self.log_index :]
logs = [
log.message
for log in raw_logs
if log.message and log.cmd_id is None
]
self.log_index += len(raw_logs)
if self._stop_event.is_set():
break
if logs:
for log_line in logs:
self.append(log_line)
if self.init_msg in log_line:
self.client_ready = True
time.sleep(1)
except Exception as e:
logger.error(f'Error streaming runloop logs: {e}')
# NB: Match LogBuffer behavior on below methods
def get_and_clear(self) -> list[str]:
with self.lock:
logs = list(self.buffer)
self.buffer.clear()
return logs
def append(self, log_line: str):
with self.lock:
self.buffer.append(log_line)
def close(self, timeout: float = 5.0):
self._stop_event.set()
self.log_stream_thread.join(timeout)
class RunloopRuntime(EventStreamRuntime):
"""The RunloopRuntime class is an EventStreamRuntime that utilizes Runloop Devbox as a runtime environment."""
_sandbox_port: int = 4444
def __init__(
self,
config: AppConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
):
assert config.runloop_api_key is not None, 'Runloop API key is required'
self.devbox: DevboxView | None = None
self.config = config
self.runloop_api_client = Runloop(
bearer_token=config.runloop_api_key,
)
self.session = requests.Session()
self.container_name = self.container_name_prefix + sid
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self.init_base_runtime(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
)
# Buffer for container logs
self.log_buffer: LogBuffer | None = None
@tenacity.retry(
stop=tenacity.stop_after_attempt(120),
wait=tenacity.wait_fixed(1),
)
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
"""Pull devbox status until it is running"""
if devbox == 'running':
return devbox
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
if devbox.status != 'running':
raise ConnectionRefusedError('Devbox is not running')
# Devbox is connected and running
logging.debug(f'devbox.id={devbox.id} is running')
return devbox
def _create_new_devbox(self) -> DevboxView:
# Note: Runloop connect
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
plugin_args = []
if self.plugins is not None and len(self.plugins) > 0:
plugin_args.append('--plugins')
plugin_args.extend([plugin.name for plugin in self.plugins])
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'-browsergym-eval-env',
self.config.sandbox.browsergym_eval_env,
]
# Copied from EventstreamRuntime
start_command = get_remote_startup_command(
self._sandbox_port,
sandbox_workspace_dir,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
)
# Add some additional commands based on our image
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
# (ie browser) to be installed as root
start_command = (
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
'cd /openhands/code && '
+ '/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
+ ' '.join(start_command)
)
entrypoint = f"sudo bash -c '{start_command}'"
devbox = self.runloop_api_client.devboxes.create(
entrypoint=entrypoint,
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
name=self.sid,
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port],
resource_size_request="LARGE",
),
metadata={'container-name': self.container_name},
)
return self._wait_for_devbox(devbox)
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
if self.attach_to_existing:
active_devboxes = self.runloop_api_client.devboxes.list(
status='running'
).devboxes
self.devbox = next(
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
)
if self.devbox is None:
self.devbox = self._create_new_devbox()
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
tunnel = self.runloop_api_client.devboxes.create_tunnel(
id=self.devbox.id,
port=self._sandbox_port,
)
# Hook up logs
self.log_buffer = RunloopLogBuffer(self.runloop_api_client, self.devbox.id)
self.api_url = f'https://{tunnel.url}'
logger.info(f'Container started. Server url: {self.api_url}')
# End Runloop connect
# NOTE: Copied from EventStreamRuntime
logger.info('Waiting for client to become ready...')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
self._wait_until_alive()
if not self.attach_to_existing:
self.setup_initial_env()
logger.info(
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
)
self.send_status_message(' ')
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=(ConnectionRefusedError,),
)
def _wait_until_alive(self):
# NB(Runloop): Remote logs are not guaranteed realtime, removing client_ready check from logs
self._refresh_logs()
if not self.log_buffer:
raise RuntimeError('Runtime client is not ready.')
response = send_request(
self.session,
'GET',
f'{self.api_url}/alive',
timeout=5,
)
if response.status_code == 200:
return
else:
msg = f'Action execution API is not alive. Response: {response}'
logger.error(msg)
raise RuntimeError(msg)
def close(self, rm_all_containers: bool = True):
if self.log_buffer:
self.log_buffer.close()
if self.session:
self.session.close()
if self.attach_to_existing:
return
if self.devbox:
self.runloop_api_client.devboxes.shutdown(self.devbox.id)

View File

@@ -1,7 +1,6 @@
import os
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
from openhands.core.logger import openhands_logger as logger
from openhands.server.sheets_client import GoogleSheetsClient
@@ -102,7 +101,6 @@ async def authenticate_github_user(auth_token) -> bool:
return True
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
async def get_github_user(token: str) -> str:
"""Get GitHub user info from token.
@@ -110,25 +108,19 @@ async def get_github_user(token: str) -> str:
token: GitHub access token
Returns:
github handle of the user
Tuple of (login, error_message)
If successful, error_message is None
If failed, login is None and error_message contains the error
"""
logger.info('Fetching GitHub user info from token')
headers = {
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {token}',
'X-GitHub-Api-Version': '2022-11-28',
}
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0)
) as client:
try:
response = await client.get('https://api.github.com/user', headers=headers)
except httpx.RequestError as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
logger.info('Received response from GitHub API')
logger.debug(f'Response status code: {response.status_code}')
async with httpx.AsyncClient() as client:
logger.debug('Making request to GitHub API')
response = await client.get('https://api.github.com/user', headers=headers)
response.raise_for_status()
user_data = response.json()
login = user_data.get('login')

View File

@@ -1,13 +1,12 @@
import asyncio
import io
import os
import re
import tempfile
import time
import uuid
import warnings
from contextlib import asynccontextmanager
import jwt
import requests
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
@@ -17,7 +16,6 @@ from openhands.server.data_models.feedback import FeedbackDataModel, store_feedb
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user,
)
from openhands.storage import get_file_store
@@ -29,7 +27,6 @@ with warnings.catch_warnings():
from dotenv import load_dotenv
from fastapi import (
BackgroundTasks,
FastAPI,
HTTPException,
Request,
@@ -37,7 +34,7 @@ from fastapi import (
WebSocket,
status,
)
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import HTTPBearer
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -63,7 +60,7 @@ from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.llm import bedrock
from openhands.runtime.base import Runtime
from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
@@ -207,22 +204,12 @@ async def attach_session(request: Request, call_next):
response = await call_next(request)
return response
user_verifier = UserVerifier()
if user_verifier.is_active():
signed_token = request.cookies.get('github_auth')
if not signed_token:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authenticated'},
)
try:
jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
github_token = request.headers.get('X-GitHub-Token')
if not await authenticate_github_user(github_token):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authenticated'},
)
if not request.headers.get('Authorization'):
logger.warning('Missing Authorization header')
@@ -803,20 +790,19 @@ async def security_api(request: Request):
@app.get('/api/zip-directory')
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
async def zip_current_workspace(request: Request):
try:
logger.debug('Zipping workspace')
runtime: Runtime = request.state.conversation.runtime
path = runtime.config.workspace_mount_path_in_sandbox
zip_file = await call_sync_from_async(runtime.copy_from, path)
response = FileResponse(
path=zip_file,
filename='workspace.zip',
media_type='application/x-zip-compressed',
)
# This will execute after the response is sent (So the file is not deleted before being sent)
background_tasks.add_task(zip_file.unlink)
path = runtime.config.workspace_mount_path_in_sandbox
zip_file_bytes = await call_sync_from_async(runtime.copy_from, path)
zip_stream = io.BytesIO(zip_file_bytes) # Wrap to behave like a file stream
response = StreamingResponse(
zip_stream,
media_type='application/x-zip-compressed',
headers={'Content-Disposition': 'attachment; filename=workspace.zip'},
)
return response
except Exception as e:
@@ -877,26 +863,10 @@ async def authenticate(request: Request):
content={'error': 'Not authorized via GitHub waitlist'},
)
# Create a signed JWT token with 1-hour expiration
cookie_data = {
'github_token': token,
'exp': int(time.time()) + 3600, # 1 hour expiration
}
signed_token = sign_token(cookie_data, config.jwt_secret)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
)
# Set secure cookie with signed token
response.set_cookie(
key='github_auth',
value=signed_token,
max_age=3600, # 1 hour in seconds
httponly=True,
secure=True,
samesite='strict',
)
return response

View File

@@ -3,11 +3,15 @@ import os
import frontmatter
import pydantic
from openhands.controller.agent import Agent
from openhands.core.exceptions import MicroAgentValidationError
from openhands.core.logger import openhands_logger as logger
class MicroAgentMetadata(pydantic.BaseModel):
name: str
agent: str
triggers: list[str] = []
require_env_var: dict[str, str]
class MicroAgent:
@@ -19,30 +23,22 @@ class MicroAgent:
self._loaded = frontmatter.load(file)
self._content = self._loaded.content
self._metadata = MicroAgentMetadata(**self._loaded.metadata)
def get_trigger(self, message: str) -> str | None:
message = message.lower()
for trigger in self.triggers:
if trigger.lower() in message:
return trigger
return None
self._validate_micro_agent()
@property
def content(self) -> str:
return self._content
@property
def metadata(self) -> MicroAgentMetadata:
return self._metadata
@property
def name(self) -> str:
return self._metadata.name
@property
def triggers(self) -> list[str]:
return self._metadata.triggers
@property
def agent(self) -> str:
return self._metadata.agent
def _validate_micro_agent(self):
logger.debug(
f'Loading and validating micro agent [{self._metadata.name}] based on [{self._metadata.agent}]'
)
# Make sure the agent is registered
agent_cls = Agent.get_cls(self._metadata.agent)
assert agent_cls is not None
# Make sure the environment variables are set
for env_var, instruction in self._metadata.require_env_var.items():
if env_var not in os.environ:
raise MicroAgentValidationError(
f'Environment variable [{env_var}] is required by micro agent [{self._metadata.name}] but not set. {instruction}'
)

View File

@@ -1,10 +1,7 @@
import os
from itertools import islice
from jinja2 import Template
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent
@@ -19,31 +16,21 @@ class PromptManager:
Attributes:
prompt_dir (str): Directory containing prompt templates.
agent_skills_docs (str): Documentation of agent skills.
micro_agent (MicroAgent | None): Micro-agent, if specified.
"""
def __init__(
self,
prompt_dir: str,
microagent_dir: str = '',
agent_skills_docs: str = '',
agent_skills_docs: str,
micro_agent: MicroAgent | None = None,
):
self.prompt_dir: str = prompt_dir
self.agent_skills_docs: str = agent_skills_docs
self.system_template: Template = self._load_template('system_prompt')
self.user_template: Template = self._load_template('user_prompt')
self.microagents: dict = {}
microagent_files = []
if microagent_dir:
microagent_files = [
os.path.join(microagent_dir, f)
for f in os.listdir(microagent_dir)
if f.endswith('.md')
]
for microagent_file in microagent_files:
microagent = MicroAgent(microagent_file)
self.microagents[microagent.name] = microagent
self.micro_agent: MicroAgent | None = micro_agent
def _load_template(self, template_name: str) -> Template:
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
@@ -52,13 +39,15 @@ class PromptManager:
with open(template_path, 'r') as file:
return Template(file.read())
def get_system_message(self) -> str:
@property
def system_message(self) -> str:
rendered = self.system_template.render(
agent_skills_docs=self.agent_skills_docs,
).strip()
return rendered
def get_example_user_message(self) -> str:
@property
def initial_user_message(self) -> str:
"""This is the initial user message provided to the agent
before *actual* user instructions are provided.
@@ -68,39 +57,7 @@ class PromptManager:
These additional context will convert the current generic agent
into a more specialized agent that is tailored to the user's task.
"""
return self.user_template.render().strip()
def enhance_message(self, message: Message) -> None:
"""Enhance the user message with additional context.
This method is used to enhance the user message with additional context
about the user's task. The additional context will convert the current
generic agent into a more specialized agent that is tailored to the user's task.
"""
if not message.content:
return
message_content = message.content[0].text
for microagent in self.microagents.values():
trigger = microagent.get_trigger(message_content)
if trigger:
micro_text = f'<extra_info>\nThe following information has been included based on a keyword match for "{trigger}". It may or may not be relevant to the user\'s request.'
micro_text += '\n\n' + microagent.content
micro_text += '\n</extra_info>'
message.content.append(TextContent(text=micro_text))
def add_turns_left_reminder(self, messages: list[Message], state: State) -> None:
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
rendered = self.user_template.render(
micro_agent=self.micro_agent.content if self.micro_agent else None
)
if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
return rendered.strip()

2959
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.13.0"
version = "0.12.3"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -61,7 +61,8 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = "^0.64.145"
runloop-api-client = "0.7.0"
pytest = "^8.3.3"
pytest-asyncio = "^0.24.0"
[tool.poetry.group.llama-index.dependencies]
llama-index = "*"
@@ -93,6 +94,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -123,6 +125,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"

View File

@@ -14,7 +14,6 @@ from openhands.events import EventStream
from openhands.runtime.base import Runtime
from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_async_from_sync
@@ -132,8 +131,6 @@ def get_runtime_classes():
return [EventStreamRuntime]
elif runtime.lower() == 'remote':
return [RemoteRuntime]
elif runtime.lower() == 'runloop':
return [RunloopRuntime]
else:
raise ValueError(f'Invalid runtime: {runtime}')

View File

@@ -1,7 +1,6 @@
"""Bash-related tests for the EventStreamRuntime, which connects to the ActionExecutor running in the sandbox."""
import os
from pathlib import Path
import pytest
from conftest import (
@@ -587,10 +586,8 @@ def test_copy_from_directory(temp_dir, runtime_cls):
path_to_copy_from = f'{sandbox_dir}/test_dir'
result = runtime.copy_from(path=path_to_copy_from)
# Result is returned as a path
assert isinstance(result, Path)
result.unlink()
# Result is returned in bytes
assert isinstance(result, bytes)
finally:
_close_test_runtime(runtime)

View File

@@ -461,7 +461,6 @@ def test_api_keys_repr_str():
jwt_secret='my_jwt_secret',
modal_api_token_id='my_modal_api_token_id',
modal_api_token_secret='my_modal_api_token_secret',
runloop_api_key='my_runloop_api_key',
)
assert "e2b_api_key='******'" in repr(app_config)
assert "e2b_api_key='******'" in str(app_config)
@@ -471,8 +470,6 @@ def test_api_keys_repr_str():
assert "modal_api_token_id='******'" in str(app_config)
assert "modal_api_token_secret='******'" in repr(app_config)
assert "modal_api_token_secret='******'" in str(app_config)
assert "runloop_api_key='******'" in repr(app_config)
assert "runloop_api_key='******'" in str(app_config)
# Check that no other attrs in AppConfig have 'key' or 'token' in their name
# This will fail when new attrs are added, and attract attention
@@ -480,7 +477,6 @@ def test_api_keys_repr_str():
'e2b_api_key',
'modal_api_token_id',
'modal_api_token_secret',
'runloop_api_key',
]
for attr_name in dir(AppConfig):
if (

View File

@@ -0,0 +1,107 @@
import asyncio
import logging
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import AppConfig
from openhands.core.main import run_controller
from openhands.events.action import MessageAction
from openhands.events import EventSource, EventStream
from openhands.events.observation import AgentStateChangedObservation
from openhands.core.schema import AgentState
from openhands.runtime.base import Runtime
from openhands.storage.files import FileStore
class MockFileStore(FileStore):
"""Mock file store for testing"""
def __init__(self):
self.files = {}
def read(self, path: str) -> str:
return self.files.get(path, "")
def write(self, path: str, contents: str) -> None:
self.files[path] = contents
def list(self, path: str) -> list[str]:
return []
def delete(self, path: str) -> None:
if path in self.files:
del self.files[path]
class MockRuntime(Runtime):
"""Mock runtime for testing"""
async def connect(self):
pass
def run(self, action):
pass
def run_ipython(self, action):
pass
def read(self, action):
pass
def write(self, action):
pass
def browse(self, action):
pass
def browse_interactive(self, action):
pass
def copy_to(self, host_src, sandbox_dest, recursive=False):
pass
def list_files(self, path=None):
return []
def copy_from(self, path):
return b""
@pytest.mark.asyncio
async def test_headless_mode_logging():
# Mock the logger
mock_logger = MagicMock()
# Create a simple message action
message = "Test message"
action = MessageAction(content=message)
# Create a minimal config
config = AppConfig()
# Create event stream and runtime
file_store = MockFileStore()
event_stream = EventStream("test", file_store)
runtime = MockRuntime(config=config, event_stream=event_stream, sid="test")
# Mock the logger.info method
with patch("openhands.core.main.logger", mock_logger):
# Run the controller in headless mode
await run_controller(
config=config,
initial_user_action=action,
headless_mode=True,
exit_on_message=True, # Exit when agent asks for input
runtime=runtime
)
# Verify that logger.info was called with events
assert mock_logger.info.call_count > 0
# Verify that at least one call was with our test message action
found_message = False
for call in mock_logger.info.call_args_list:
args = call[0]
if isinstance(args[0], MessageAction) and args[0].content == message:
found_message = True
break
assert found_message, "Test message action was not logged"

View File

@@ -1,8 +1,13 @@
import os
import pytest
from pytest import MonkeyPatch
import openhands.agenthub # noqa: F401
from openhands.core.exceptions import (
AgentNotRegisteredError,
MicroAgentValidationError,
)
from openhands.utils.microagent import MicroAgent
CONTENT = (
@@ -29,3 +34,40 @@ def test_micro_agent_load(tmp_path, monkeypatch: MonkeyPatch):
micro_agent = MicroAgent(os.path.join(tmp_path, 'dummy.md'))
assert micro_agent is not None
assert micro_agent.content == CONTENT.strip()
def test_not_existing_agent(tmp_path, monkeypatch: MonkeyPatch):
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
f.write(
(
'---\n'
'name: dummy\n'
'agent: NotExistingAgent\n'
'require_env_var:\n'
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
'---\n' + CONTENT
)
)
monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value')
with pytest.raises(AgentNotRegisteredError):
MicroAgent(os.path.join(tmp_path, 'dummy.md'))
def test_not_existing_env_var(tmp_path):
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
f.write(
(
'---\n'
'name: dummy\n'
'agent: CodeActAgent\n'
'require_env_var:\n'
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
'---\n' + CONTENT
)
)
with pytest.raises(MicroAgentValidationError) as excinfo:
MicroAgent(os.path.join(tmp_path, 'dummy.md'))
assert 'Set this environment variable for testing purposes' in str(excinfo.value)

View File

@@ -1,9 +1,9 @@
import os
import shutil
from unittest.mock import Mock
import pytest
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager
@@ -11,9 +11,7 @@ from openhands.utils.prompt import PromptManager
@pytest.fixture
def prompt_dir(tmp_path):
# Copy contents from "openhands/agenthub/codeact_agent" to the temp directory
shutil.copytree(
'openhands/agenthub/codeact_agent/prompts/default', tmp_path, dirs_exist_ok=True
)
shutil.copytree('openhands/agenthub/codeact_agent', tmp_path, dirs_exist_ok=True)
# Return the temporary directory path
return tmp_path
@@ -27,79 +25,78 @@ def agent_skills_docs():
return SAMPLE_AGENT_SKILLS_DOCS
def test_prompt_manager_without_microagent(prompt_dir, agent_skills_docs):
manager = PromptManager(
prompt_dir, microagent_dir='', agent_skills_docs=agent_skills_docs
)
def test_prompt_manager_without_micro_agent(prompt_dir, agent_skills_docs):
manager = PromptManager(prompt_dir, agent_skills_docs)
assert manager.prompt_dir == prompt_dir
assert manager.agent_skills_docs == agent_skills_docs
assert len(manager.microagents) == 0
assert manager.micro_agent is None
assert isinstance(manager.get_system_message(), str)
assert isinstance(manager.system_message, str)
assert (
"A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions."
in manager.get_system_message()
in manager.system_message
)
assert SAMPLE_AGENT_SKILLS_DOCS in manager.get_system_message()
assert isinstance(manager.get_example_user_message(), str)
assert '--- BEGIN OF GUIDELINE ---' not in manager.get_example_user_message()
assert '--- END OF GUIDELINE ---' not in manager.get_example_user_message()
assert "NOW, LET'S START!" in manager.get_example_user_message()
assert 'microagent' not in manager.get_example_user_message()
assert SAMPLE_AGENT_SKILLS_DOCS in manager.system_message
assert isinstance(manager.initial_user_message, str)
assert '--- BEGIN OF GUIDELINE ---' not in manager.initial_user_message
assert '--- END OF GUIDELINE ---' not in manager.initial_user_message
assert "NOW, LET'S START!" in manager.initial_user_message
assert 'micro_agent' not in manager.initial_user_message
def test_prompt_manager_with_microagent(prompt_dir, agent_skills_docs):
microagent_name = 'test_microagent'
microagent_content = """
---
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
"""
def test_prompt_manager_with_micro_agent(prompt_dir, agent_skills_docs):
micro_agent_name = 'test_micro_agent'
micro_agent_content = (
'## Micro Agent\n'
'This is a test micro agent.\n'
'It is used to test the prompt manager.\n'
)
# Create a temporary micro agent file
os.makedirs(os.path.join(prompt_dir, 'micro'), exist_ok=True)
with open(os.path.join(prompt_dir, 'micro', f'{microagent_name}.md'), 'w') as f:
f.write(microagent_content)
with open(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'), 'w') as f:
f.write(micro_agent_content)
# Mock MicroAgent
mock_micro_agent = Mock(spec=MicroAgent)
mock_micro_agent.content = micro_agent_content
manager = PromptManager(
prompt_dir=prompt_dir,
microagent_dir=os.path.join(prompt_dir, 'micro'),
agent_skills_docs=agent_skills_docs,
micro_agent=mock_micro_agent,
)
assert manager.prompt_dir == prompt_dir
assert manager.agent_skills_docs == agent_skills_docs
assert len(manager.microagents) == 1
assert manager.micro_agent == mock_micro_agent
assert isinstance(manager.get_system_message(), str)
assert isinstance(manager.system_message, str)
assert (
"A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions."
in manager.get_system_message()
in manager.system_message
)
assert SAMPLE_AGENT_SKILLS_DOCS in manager.get_system_message()
assert SAMPLE_AGENT_SKILLS_DOCS in manager.system_message
assert isinstance(manager.get_example_user_message(), str)
assert isinstance(manager.initial_user_message, str)
assert (
'--- BEGIN OF GUIDELINE ---\n'
+ 'The following information may assist you in completing your task:\n\n'
+ micro_agent_content
+ '\n'
+ '--- END OF GUIDELINE ---\n'
+ "\n\nNOW, LET'S START!"
) in manager.initial_user_message
assert micro_agent_content in manager.initial_user_message
message = Message(
role='user',
content=[TextContent(text='Hello, flarglebargle!')],
)
manager.enhance_message(message)
assert 'magic word' in message.content[1].text
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent_name}.md'))
# Clean up the temporary file
os.remove(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'))
def test_prompt_manager_file_not_found(prompt_dir, agent_skills_docs):
with pytest.raises(FileNotFoundError):
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md'))
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_micro_agent.md'))
def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
@@ -107,14 +104,12 @@ def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
with open(os.path.join(prompt_dir, 'system_prompt.j2'), 'w') as f:
f.write('System prompt: {{ agent_skills_docs }}')
with open(os.path.join(prompt_dir, 'user_prompt.j2'), 'w') as f:
f.write('User prompt: foo')
f.write('User prompt: {{ micro_agent }}')
manager = PromptManager(
prompt_dir, microagent_dir='', agent_skills_docs=agent_skills_docs
)
manager = PromptManager(prompt_dir, agent_skills_docs)
assert manager.get_system_message() == f'System prompt: {agent_skills_docs}'
assert manager.get_example_user_message() == 'User prompt: foo'
assert manager.system_message == f'System prompt: {agent_skills_docs}'
assert manager.initial_user_message == 'User prompt: None'
# Clean up temporary files
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))