mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17d06691a |
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)!
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #}
|
||||
@@ -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!
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}'
|
||||
)
|
||||
|
||||
@@ -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
2959
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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 = "*"
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
107
tests/unit/test_headless_logging.py
Normal file
107
tests/unit/test_headless_logging.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user