mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
29 Commits
rb/github-
...
rb/debug-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1be8bb80ba | ||
|
|
ad6b91e866 | ||
|
|
12f77ed68a | ||
|
|
a93f1402de | ||
|
|
c492c60564 | ||
|
|
bc3f0ac24a | ||
|
|
f55ddbed0e | ||
|
|
fd81670ba8 | ||
|
|
79ed4e3567 | ||
|
|
b3fbbbaa9d | ||
|
|
87c02177d7 | ||
|
|
207df9dd30 | ||
|
|
59f7093428 | ||
|
|
123fb4b75d | ||
|
|
40e2d28e87 | ||
|
|
c555611d58 | ||
|
|
50e7da9c3d | ||
|
|
0cfb132ab7 | ||
|
|
17f4c6e1a9 | ||
|
|
910b283ac2 | ||
|
|
b54724ac3f | ||
|
|
0633a99298 | ||
|
|
d9c5f11046 | ||
|
|
32fdcd58e5 | ||
|
|
de71b7cdb8 | ||
|
|
04aeccfb69 | ||
|
|
4eea1286d4 | ||
|
|
488a320ffd | ||
|
|
377fadc2eb |
2
.github/workflows/ghcr-build.yml
vendored
2
.github/workflows/ghcr-build.yml
vendored
@@ -286,7 +286,6 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
@@ -364,7 +363,6 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
|
||||
20
docs/modules/usage/llms/litellm-proxy.md
Normal file
20
docs/modules/usage/llms/litellm-proxy.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# LiteLLM Proxy
|
||||
|
||||
OpenHands supports using the [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/quick_start) to access various LLM providers.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use LiteLLM proxy with OpenHands, you need to:
|
||||
|
||||
1. Set up a LiteLLM proxy server (see [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/quick_start))
|
||||
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
|
||||
* Enable `Advanced Options`
|
||||
* `Custom Model` to the prefix `litellm_proxy/` + the model you will be using (e.g. `litellm_proxy/anthropic.claude-3-5-sonnet-20241022-v2:0`)
|
||||
* `Base URL` to your LiteLLM proxy URL (e.g. `https://your-litellm-proxy.com`)
|
||||
* `API Key` to your LiteLLM proxy API key
|
||||
|
||||
## Supported Models
|
||||
|
||||
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy is configured to handle.
|
||||
|
||||
Refer to your LiteLLM proxy configuration for the list of available models and their names.
|
||||
@@ -63,6 +63,7 @@ We have a few guides for running OpenHands with specific model providers:
|
||||
- [Azure](llms/azure-llms)
|
||||
- [Google](llms/google-llms)
|
||||
- [Groq](llms/groq)
|
||||
- [LiteLLM Proxy](llms/litellm-proxy)
|
||||
- [OpenAI](llms/openai-llms)
|
||||
- [OpenRouter](llms/openrouter)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ docker run # ...
|
||||
-e RUNTIME=remote \
|
||||
-e SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.app.all-hands.dev" \
|
||||
-e SANDBOX_API_KEY="your-all-hands-api-key" \
|
||||
-e SANDBOX_KEEP_REMOTE_RUNTIME_ALIVE="true" \
|
||||
-e SANDBOX_KEEP_RUNTIME_ALIVE="true" \
|
||||
# ...
|
||||
```
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Groq',
|
||||
id: 'usage/llms/groq',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'LiteLLM Proxy',
|
||||
id: 'usage/llms/litellm-proxy',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'OpenAI',
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_config(
|
||||
browsergym_eval_env=env_id,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -72,7 +72,7 @@ def get_config(
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
import pandas as pd
|
||||
from swebench.harness.grading import get_eval_report
|
||||
@@ -94,13 +95,28 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata | None = None,
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
log_dir: str | None = None,
|
||||
) -> EvalOutput:
|
||||
"""
|
||||
Evaluate agent performance on a SWE-bench problem instance.
|
||||
|
||||
Note that this signature differs from the expected input to `run_evaluation`. Use
|
||||
`functools.partial` to provide optional arguments before passing to the evaluation harness.
|
||||
|
||||
Args:
|
||||
log_dir (str | None, default=None): Path to directory where log files will be written. Must
|
||||
be provided if `reset_logger` is set.
|
||||
|
||||
Raises:
|
||||
AssertionError: if the `reset_logger` flag is set without a provided log directory.
|
||||
"""
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
global output_file
|
||||
log_dir = output_file.replace('.jsonl', '.logs')
|
||||
assert (
|
||||
log_dir is not None
|
||||
), "Can't reset logger without a provided log directory."
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
|
||||
else:
|
||||
@@ -127,6 +143,7 @@ def process_instance(
|
||||
return EvalOutput(
|
||||
instance_id=instance_id,
|
||||
test_result=instance['test_result'],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
runtime = create_runtime(config)
|
||||
@@ -176,6 +193,7 @@ def process_instance(
|
||||
return EvalOutput(
|
||||
instance_id=instance_id,
|
||||
test_result=instance['test_result'],
|
||||
metadata=metadata,
|
||||
)
|
||||
elif 'APPLY_PATCH_PASS' in apply_patch_output:
|
||||
logger.info(f'[{instance_id}] {APPLY_PATCH_PASS}:\n{apply_patch_output}')
|
||||
@@ -269,6 +287,7 @@ def process_instance(
|
||||
return EvalOutput(
|
||||
instance_id=instance_id,
|
||||
test_result=instance['test_result'],
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
@@ -355,12 +374,26 @@ if __name__ == '__main__':
|
||||
output_file = args.input_file.replace('.jsonl', '.swebench_eval.jsonl')
|
||||
instances = prepare_dataset(predictions, output_file, args.eval_n_limit)
|
||||
|
||||
# If possible, load the relevant metadata to avoid issues with `run_evaluation`.
|
||||
metadata: EvalMetadata | None = None
|
||||
metadata_filepath = os.path.join(os.path.dirname(args.input_file), 'metadata.json')
|
||||
if os.path.exists(metadata_filepath):
|
||||
with open(metadata_filepath, 'r') as metadata_file:
|
||||
data = metadata_file.read()
|
||||
metadata = EvalMetadata.model_validate_json(data)
|
||||
|
||||
# The evaluation harness constrains the signature of `process_instance_func` but we need to
|
||||
# pass extra information. Build a new function object to avoid issues with multiprocessing.
|
||||
process_instance_func = partial(
|
||||
process_instance, log_dir=output_file.replace('.jsonl', '.logs')
|
||||
)
|
||||
|
||||
run_evaluation(
|
||||
instances,
|
||||
metadata=None,
|
||||
metadata=metadata,
|
||||
output_file=output_file,
|
||||
num_workers=args.eval_num_workers,
|
||||
process_instance_func=process_instance,
|
||||
process_instance_func=process_instance_func,
|
||||
)
|
||||
|
||||
# Load evaluated predictions & print number of resolved predictions
|
||||
|
||||
@@ -36,8 +36,8 @@ from openhands.events.action import CmdRunAction, MessageAction
|
||||
from openhands.events.observation import CmdOutputObservation, ErrorObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.utils.shutdown_listener import sleep_if_should_continue
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
from openhands.utils.shutdown_listener import sleep_if_should_continue
|
||||
|
||||
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
|
||||
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'false').lower() == 'true'
|
||||
@@ -145,7 +145,7 @@ def get_config(
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
|
||||
@@ -346,6 +346,7 @@ def run_evaluation(
|
||||
f'model {metadata.llm_config.model}, max iterations {metadata.max_iterations}.\n'
|
||||
)
|
||||
else:
|
||||
logger.warning('Running evaluation without metadata.')
|
||||
logger.info(f'Evaluation started with {num_workers} workers.')
|
||||
|
||||
total_instances = len(dataset)
|
||||
|
||||
40
frontend/__tests__/clear-session.test.ts
Normal file
40
frontend/__tests__/clear-session.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { clearSession } from "../src/utils/clear-session";
|
||||
import store from "../src/store";
|
||||
import { initialState as browserInitialState } from "../src/state/browserSlice";
|
||||
|
||||
describe("clearSession", () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
|
||||
// Set initial browser state to non-default values
|
||||
store.dispatch({
|
||||
type: "browser/setUrl",
|
||||
payload: "https://example.com",
|
||||
});
|
||||
store.dispatch({
|
||||
type: "browser/setScreenshotSrc",
|
||||
payload: "base64screenshot",
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear localStorage and reset browser state", () => {
|
||||
clearSession();
|
||||
|
||||
// Verify localStorage items were removed
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith("token");
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith("repo");
|
||||
|
||||
// Verify browser state was reset
|
||||
const state = store.getState();
|
||||
expect(state.browser.url).toBe(browserInitialState.url);
|
||||
expect(state.browser.screenshotSrc).toBe(browserInitialState.screenshotSrc);
|
||||
});
|
||||
});
|
||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
@@ -26,7 +26,7 @@
|
||||
"isbot": "^5.1.17",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-js": "^1.184.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -19749,9 +19749,9 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.176.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.176.0.tgz",
|
||||
"integrity": "sha512-T5XKNtRzp7q6CGb7Vc7wAI76rWap9fiuDUPxPsyPBPDkreKya91x9RIsSapAVFafwD1AEin1QMczCmt9Le9BWw==",
|
||||
"version": "1.184.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.184.1.tgz",
|
||||
"integrity": "sha512-q/1Kdard5SZnL2smrzeKcD+RuUi2PnbidiN4D3ThK20bNrhy5Z2heIy9SnRMvEiARY5lcQ7zxmDCAKPBKGSOtQ==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"isbot": "^5.1.17",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-js": "^1.184.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -120,4 +120,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": ""
|
||||
}
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GitHubAccessTokenResponse,
|
||||
ErrorResponse,
|
||||
GetConfigResponse,
|
||||
GetVSCodeUrlResponse,
|
||||
} from "./open-hands.types";
|
||||
|
||||
class OpenHands {
|
||||
@@ -174,6 +175,14 @@ class OpenHands {
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL
|
||||
* @returns VSCode URL
|
||||
*/
|
||||
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
|
||||
return request(`/api/vscode-url`, {}, false, false, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -43,5 +43,11 @@ export interface Feedback {
|
||||
|
||||
export interface GetConfigResponse {
|
||||
APP_MODE: "saas" | "oss";
|
||||
GITHUB_CLIENT_ID: string | null;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
}
|
||||
|
||||
export interface GetVSCodeUrlResponse {
|
||||
vscode_url: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
57
frontend/src/assets/vscode-alt.svg
Normal file
57
frontend/src/assets/vscode-alt.svg
Normal file
@@ -0,0 +1,57 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<g filter="url(#filter0_d)">
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.5723C72.4869 100.189 74.2828 100.15 75.8725 99.3807L96.4604 89.4231C98.624 88.3771 100 86.1762 100 83.7616V16.2392C100 13.8247 98.624 11.6238 96.4604 10.5774L75.8725 0.619067C73.7862 -0.389991 71.3446 -0.142885 69.5135 1.19527C69.252 1.38636 69.0028 1.59985 68.769 1.83502L29.3551 37.9795L12.1872 24.88C10.5891 23.6607 8.35365 23.7606 6.86938 25.1178L1.36302 30.1525C-0.452603 31.8127 -0.454583 34.6837 1.35854 36.3466L16.2471 50.0001L1.35854 63.6536C-0.454583 65.3164 -0.452603 68.1876 1.36302 69.8477L6.86938 74.8824C8.35365 76.2395 10.5891 76.34 12.1872 75.1201L29.3551 62.0207L68.769 98.1651C69.3925 98.7923 70.1246 99.2645 70.9119 99.5723ZM75.0152 27.1813L45.1092 50.0001L75.0152 72.8189V27.1813Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M96.4614 10.593L75.8567 0.62085C73.4717 -0.533437 70.6215 -0.0465506 68.7498 1.83492L1.29834 63.6535C-0.515935 65.3164 -0.513852 68.1875 1.30281 69.8476L6.8125 74.8823C8.29771 76.2395 10.5345 76.339 12.1335 75.1201L93.3604 13.18C96.0854 11.102 100 13.0557 100 16.4939V16.2535C100 13.84 98.6239 11.64 96.4614 10.593Z" fill="#D9D9D9"/>
|
||||
<g filter="url(#filter1_d)">
|
||||
<path d="M96.4614 89.4074L75.8567 99.3797C73.4717 100.534 70.6215 100.047 68.7498 98.1651L1.29834 36.3464C-0.515935 34.6837 -0.513852 31.8125 1.30281 30.1524L6.8125 25.1177C8.29771 23.7605 10.5345 23.6606 12.1335 24.88L93.3604 86.8201C96.0854 88.8985 100 86.9447 100 83.5061V83.747C100 86.1604 98.6239 88.3603 96.4614 89.4074Z" fill="#E6E6E6"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d)">
|
||||
<path d="M75.8578 99.3807C73.4721 100.535 70.6219 100.047 68.75 98.1651C71.0564 100.483 75 98.8415 75 95.5631V4.43709C75 1.15852 71.0565 -0.483493 68.75 1.83492C70.6219 -0.0467614 73.4721 -0.534276 75.8578 0.618963L96.4583 10.5773C98.6229 11.6237 100 13.8246 100 16.2391V83.7616C100 86.1762 98.6229 88.3761 96.4583 89.4231L75.8578 99.3807Z" fill="white"/>
|
||||
</g>
|
||||
<g style="mix-blend-mode:overlay" opacity="0.25">
|
||||
<path style="mix-blend-mode:overlay" opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M70.8508 99.5723C72.4258 100.189 74.2218 100.15 75.8115 99.3807L96.4 89.4231C98.5635 88.3771 99.9386 86.1762 99.9386 83.7616V16.2391C99.9386 13.8247 98.5635 11.6239 96.4 10.5774L75.8115 0.618974C73.7252 -0.390085 71.2835 -0.142871 69.4525 1.19518C69.1909 1.38637 68.9418 1.59976 68.7079 1.83493L29.2941 37.9795L12.1261 24.88C10.528 23.6606 8.2926 23.7605 6.80833 25.1177L1.30198 30.1524C-0.51354 31.8126 -0.515625 34.6837 1.2975 36.3465L16.186 50L1.2975 63.6536C-0.515625 65.3164 -0.51354 68.1875 1.30198 69.8476L6.80833 74.8824C8.2926 76.2395 10.528 76.339 12.1261 75.1201L29.2941 62.0207L68.7079 98.1651C69.3315 98.7923 70.0635 99.2645 70.8508 99.5723ZM74.9542 27.1812L45.0481 50L74.9542 72.8188V27.1812Z" fill="url(#paint0_linear)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="-6.25" y="-4.16667" width="112.5" height="112.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dy="2.08333"/>
|
||||
<feGaussianBlur stdDeviation="3.125"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d" x="-8.39436" y="15.6951" width="116.728" height="92.6376" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.16667"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d" x="60.4167" y="-8.33346" width="47.9167" height="116.667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.16667"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear" x1="49.939" y1="-5.19792e-05" x2="49.939" y2="100.001" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0">
|
||||
<rect width="100" height="100" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -14,7 +14,6 @@ import {
|
||||
} from "#/context/ws-client-provider";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import {
|
||||
getCloneRepoCommand,
|
||||
getGitHubTokenCommand,
|
||||
@@ -34,6 +33,7 @@ import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { setCurrentAgentState } from "#/state/agentSlice";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { getSettings } from "#/services/settings";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@@ -96,6 +96,14 @@ export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const message: string = `${event.message}`;
|
||||
if (message.startsWith("Agent reached maximum")) {
|
||||
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
|
||||
send(generateAgentStateChangeEvent(AgentState.PAUSED));
|
||||
}
|
||||
}
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
dispatch(
|
||||
addErrorMessage({
|
||||
@@ -103,9 +111,7 @@ export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
message: event.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleAssistantMessage(event);
|
||||
}, [events.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { setRefreshID } from "#/state/codeSlice";
|
||||
import { addAssistantMessage } from "#/state/chatSlice";
|
||||
import IconButton from "../IconButton";
|
||||
import ExplorerTree from "./ExplorerTree";
|
||||
import toast from "#/utils/toast";
|
||||
@@ -20,6 +21,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
|
||||
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
|
||||
|
||||
interface ExplorerActionsProps {
|
||||
onRefresh: () => void;
|
||||
@@ -168,6 +170,35 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVSCodeClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await OpenHands.getVSCodeUrl();
|
||||
if (response.vscode_url) {
|
||||
dispatch(
|
||||
addAssistantMessage(
|
||||
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
|
||||
),
|
||||
);
|
||||
window.open(response.vscode_url, "_blank");
|
||||
} else {
|
||||
toast.error(
|
||||
`open-vscode-error-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: response.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (exp_error) {
|
||||
toast.error(
|
||||
`open-vscode-error-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: String(exp_error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshWorkspace();
|
||||
}, [curAgentState]);
|
||||
@@ -210,7 +241,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
!isOpen ? "w-12" : "w-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col relative h-full px-3 py-2">
|
||||
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
|
||||
<div className="sticky top-0 bg-neutral-800">
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -232,7 +263,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
</div>
|
||||
</div>
|
||||
{!error && (
|
||||
<div className="overflow-auto flex-grow">
|
||||
<div className="overflow-auto flex-grow min-h-0">
|
||||
<div style={{ display: !isOpen ? "none" : "block" }}>
|
||||
<ExplorerTree files={paths} />
|
||||
</div>
|
||||
@@ -243,6 +274,27 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
<p className="text-neutral-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVSCodeClick}
|
||||
disabled={
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING
|
||||
}
|
||||
className={twMerge(
|
||||
"mt-auto mb-2 w-full h-10 text-white rounded flex items-center justify-center gap-2 transition-colors",
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING
|
||||
? "bg-neutral-600 cursor-not-allowed"
|
||||
: "bg-[#4465DB] hover:bg-[#3451C7]",
|
||||
)}
|
||||
aria-label="Open in VS Code"
|
||||
>
|
||||
<VSCodeIcon width={20} height={20} />
|
||||
Open in VS Code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -59,11 +59,6 @@ export function InteractiveChatBox({
|
||||
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
|
||||
"transition-colors duration-200",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Settings } from "#/services/settings";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
|
||||
const RECONNECT_RETRIES = 5;
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
STOPPED,
|
||||
@@ -46,6 +49,7 @@ export function WsClientProvider({
|
||||
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
const [retryCount, setRetryCount] = React.useState(RECONNECT_RETRIES);
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!wsRef.current) {
|
||||
@@ -56,6 +60,7 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
setRetryCount(RECONNECT_RETRIES);
|
||||
setStatus(WsClientProviderStatus.OPENING);
|
||||
const initEvent = {
|
||||
action: ActionType.INIT,
|
||||
@@ -76,11 +81,19 @@ export function WsClientProvider({
|
||||
) {
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setStatus(WsClientProviderStatus.STOPPED);
|
||||
setEvents([]);
|
||||
if (retryCount) {
|
||||
setTimeout(() => {
|
||||
setRetryCount(retryCount - 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setStatus(WsClientProviderStatus.STOPPED);
|
||||
setEvents([]);
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
@@ -95,7 +108,7 @@ export function WsClientProvider({
|
||||
let ws = wsRef.current;
|
||||
|
||||
// If disabled close any existing websockets...
|
||||
if (!enabled) {
|
||||
if (!enabled || !retryCount) {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
@@ -116,7 +129,11 @@ export function WsClientProvider({
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
|
||||
let wsUrl = `${protocol}//${baseUrl}/ws`;
|
||||
if (events.length) {
|
||||
wsUrl += `?latest_event_id=${events[events.length - 1].id}`;
|
||||
}
|
||||
ws = new WebSocket(wsUrl, [
|
||||
"openhands",
|
||||
token || "NO_JWT",
|
||||
ghToken || "NO_GITHUB",
|
||||
@@ -136,7 +153,7 @@ export function WsClientProvider({
|
||||
ws.removeEventListener("error", handleError);
|
||||
ws.removeEventListener("close", handleClose);
|
||||
};
|
||||
}, [enabled, token, ghToken]);
|
||||
}, [enabled, token, ghToken, retryCount]);
|
||||
|
||||
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
|
||||
// before actually closing the socket and cancel the operation if the component gets remounted.
|
||||
@@ -148,7 +165,11 @@ export function WsClientProvider({
|
||||
|
||||
return () => {
|
||||
closeRef.current = setTimeout(() => {
|
||||
wsRef.current?.close();
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.removeEventListener("close", handleClose);
|
||||
ws.close();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -12,15 +12,26 @@ import { Provider } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import "./i18n";
|
||||
import store from "./store";
|
||||
import OpenHands from "./api/open-hands";
|
||||
|
||||
function PosthogInit() {
|
||||
const [key, setKey] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
posthog.init("phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA", {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
OpenHands.getConfig().then((config) => {
|
||||
setKey(config.POSTHOG_CLIENT_KEY);
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (key) {
|
||||
posthog.init(key, {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -678,6 +678,16 @@
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı",
|
||||
"no": "Uventet responsstruktur fra serveren"
|
||||
},
|
||||
"EXPLORER$VSCODE_SWITCHING_MESSAGE": {
|
||||
"en": "Switching to VS Code in 3 seconds...\nImportant: Please inform the agent of any changes you make in VS Code. To avoid conflicts, wait for the assistant to complete its work before making your own changes.",
|
||||
"zh-CN": "3 秒后切换到 VS Code\n重要提示:请告知 OpenHands 您在 VS Code 中进行的任何更改。为了避免冲突,请在 OpenHands 完成工作后再进行自己的更改。",
|
||||
"zh-TW": "3 秒後切換到 VS Code\n重要提示:請告知 OpenHands 您在 VS Code 中進行的任何更改。為避免衝突,請在 OpenHands 完成工作後再進行自己的更改。"
|
||||
},
|
||||
"EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE": {
|
||||
"en": "Error switching to VS Code: {{error}}",
|
||||
"zh-CN": "切换到 VS Code 时发生错误: {{error}}",
|
||||
"zh-TW": "切換到 VS Code 時發生錯誤: {{error}}"
|
||||
},
|
||||
"LOAD_SESSION$MODAL_TITLE": {
|
||||
"en": "Return to existing session?",
|
||||
"de": "Zurück zu vorhandener Sitzung?",
|
||||
|
||||
@@ -71,8 +71,6 @@ const openHandsHandlers = [
|
||||
export const handlers = [
|
||||
...openHandsHandlers,
|
||||
http.get("https://api.github.com/user/repos", async ({ request }) => {
|
||||
if (import.meta.env.MODE !== "test") await delay(3500);
|
||||
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
|
||||
@@ -29,7 +29,7 @@ const generateAgentResponse = (message: string): AssistantMessageAction => ({
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
images_urls: [],
|
||||
image_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,11 +70,6 @@ export function TaskForm() {
|
||||
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
|
||||
inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
|
||||
@@ -171,6 +171,8 @@ export default function MainApp() {
|
||||
company: user.company,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
user: user.login,
|
||||
mode: window.__APP_MODE__ || "oss",
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
@@ -2,12 +2,12 @@ import ActionType from "#/types/ActionType";
|
||||
|
||||
export function createChatMessage(
|
||||
message: string,
|
||||
images_urls: string[],
|
||||
image_urls: string[],
|
||||
timestamp: string,
|
||||
) {
|
||||
const event = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: message, images_urls, timestamp },
|
||||
args: { content: message, image_urls, timestamp },
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
|
||||
source: "user";
|
||||
args: {
|
||||
content: string;
|
||||
images_urls: string[];
|
||||
image_urls: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface AssistantMessageAction
|
||||
source: "agent";
|
||||
args: {
|
||||
content: string;
|
||||
images_urls: string[] | null;
|
||||
image_urls: string[] | null;
|
||||
wait_for_response: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ interface LocalUserMessageAction {
|
||||
action: "message";
|
||||
args: {
|
||||
content: string;
|
||||
images_urls: string[];
|
||||
image_urls: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import store from "#/store";
|
||||
import { initialState as browserInitialState } from "#/state/browserSlice";
|
||||
|
||||
/**
|
||||
* Clear the session data from the local storage. This will remove the token and repo
|
||||
* Clear the session data from the local storage and reset relevant Redux state
|
||||
*/
|
||||
export const clearSession = () => {
|
||||
// Clear local storage
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("repo");
|
||||
|
||||
// Reset browser state to initial values
|
||||
store.dispatch({
|
||||
type: "browser/setUrl",
|
||||
payload: browserInitialState.url,
|
||||
});
|
||||
store.dispatch({
|
||||
type: "browser/setScreenshotSrc",
|
||||
payload: browserInitialState.screenshotSrc,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,14 +13,14 @@ const KEY_2 = "Auto-merge Dependabot PRs";
|
||||
const VALUE_2 = `Please add a GitHub action to this repository which automatically merges pull requests from Dependabot so long as the tests are passing.`;
|
||||
|
||||
const KEY_3 = "Fix up my README";
|
||||
const VALUE_3 = `"Please look at the README and make the following improvements, if they make sense:
|
||||
const VALUE_3 = `Please look at the README and make the following improvements, if they make sense:
|
||||
* correct any typos that you find
|
||||
* add missing language annotations on codeblocks
|
||||
* if there are references to other files or other sections of the README, turn them into links
|
||||
* make sure the readme has an h1 title towards the top
|
||||
* make sure any existing sections in the readme are appropriately separated with headings
|
||||
|
||||
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier"`;
|
||||
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier`;
|
||||
|
||||
const KEY_4 = "Clean up my dependencies";
|
||||
const VALUE_4 = `Examine the dependencies of the current codebase. Make sure you can run the code and any tests.
|
||||
|
||||
@@ -10,7 +10,6 @@ export default {
|
||||
style: {
|
||||
background: "#ef4444",
|
||||
color: "#fff",
|
||||
lineBreak: "anywhere",
|
||||
},
|
||||
iconTheme: {
|
||||
primary: "#ef4444",
|
||||
@@ -19,25 +18,20 @@ export default {
|
||||
});
|
||||
idMap.set(id, toastId);
|
||||
},
|
||||
success: (id: string, msg: string) => {
|
||||
const toastId = idMap.get(id);
|
||||
if (toastId === undefined) return;
|
||||
if (toastId) {
|
||||
toast.success(msg, {
|
||||
id: toastId,
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
lineBreak: "anywhere",
|
||||
},
|
||||
iconTheme: {
|
||||
primary: "#333",
|
||||
secondary: "#fff",
|
||||
},
|
||||
});
|
||||
}
|
||||
idMap.delete(id);
|
||||
success: (id: string, msg: string, duration: number = 4000) => {
|
||||
if (idMap.has(id)) return; // prevent duplicate toast
|
||||
const toastId = toast.success(msg, {
|
||||
duration,
|
||||
style: {
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
},
|
||||
iconTheme: {
|
||||
primary: "#333",
|
||||
secondary: "#fff",
|
||||
},
|
||||
});
|
||||
idMap.set(id, toastId);
|
||||
},
|
||||
settingsChanged: (msg: string) => {
|
||||
toast(msg, {
|
||||
@@ -48,7 +42,6 @@ export default {
|
||||
style: {
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
lineBreak: "anywhere",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -103,15 +103,17 @@ class CodeActAgent(Agent):
|
||||
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
|
||||
)
|
||||
self.prompt_manager = PromptManager(
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
|
||||
disabled_microagents=self.config.disabled_microagents,
|
||||
)
|
||||
else:
|
||||
self.action_parser = CodeActResponseParser()
|
||||
self.prompt_manager = PromptManager(
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
|
||||
agent_skills_docs=AgentSkillsRequirement.documentation,
|
||||
disabled_microagents=self.config.disabled_microagents,
|
||||
)
|
||||
|
||||
self.pending_actions: deque[Action] = deque()
|
||||
@@ -196,8 +198,8 @@ class CodeActAgent(Agent):
|
||||
elif isinstance(action, MessageAction):
|
||||
role = 'user' if action.source == 'user' else 'assistant'
|
||||
content = [TextContent(text=action.content or '')]
|
||||
if self.llm.vision_is_active() and action.images_urls:
|
||||
content.append(ImageContent(image_urls=action.images_urls))
|
||||
if self.llm.vision_is_active() and action.image_urls:
|
||||
content.append(ImageContent(image_urls=action.image_urls))
|
||||
return [
|
||||
Message(
|
||||
role=role,
|
||||
|
||||
@@ -95,9 +95,9 @@ class CodeActSWEAgent(Agent):
|
||||
if (
|
||||
self.llm.vision_is_active()
|
||||
and isinstance(action, MessageAction)
|
||||
and action.images_urls
|
||||
and action.image_urls
|
||||
):
|
||||
content.append(ImageContent(image_urls=action.images_urls))
|
||||
content.append(ImageContent(image_urls=action.image_urls))
|
||||
|
||||
return Message(
|
||||
role='user' if action.source == 'user' else 'assistant', content=content
|
||||
|
||||
@@ -42,7 +42,7 @@ from openhands.events.observation import (
|
||||
)
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
# note: RESUME is only available on web GUI
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
|
||||
@@ -149,7 +149,7 @@ class State:
|
||||
for event in reversed(self.history):
|
||||
if isinstance(event, MessageAction) and event.source == 'user':
|
||||
last_user_message = event.content
|
||||
last_user_message_image_urls = event.images_urls
|
||||
last_user_message_image_urls = event.image_urls
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
if last_user_message is not None:
|
||||
return last_user_message, None
|
||||
|
||||
@@ -116,6 +116,7 @@ async def main():
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
plugins=agent_cls.sandbox_plugins,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
controller = AgentController(
|
||||
|
||||
@@ -16,6 +16,8 @@ class AgentConfig:
|
||||
memory_enabled: Whether long-term memory (embeddings) is enabled.
|
||||
memory_max_threads: The maximum number of threads indexing at the same time for embeddings.
|
||||
llm_config: The name of the llm config to use. If specified, this will override global llm config.
|
||||
use_microagents: Whether to use microagents at all. Default is True.
|
||||
disabled_microagents: A list of microagents to disable. Default is None.
|
||||
"""
|
||||
|
||||
function_calling: bool = True
|
||||
@@ -26,6 +28,8 @@ class AgentConfig:
|
||||
memory_enabled: bool = False
|
||||
memory_max_threads: int = 3
|
||||
llm_config: str | None = None
|
||||
use_microagents: bool = True
|
||||
disabled_microagents: list[str] | None = None
|
||||
|
||||
def defaults_to_dict(self) -> dict:
|
||||
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
|
||||
|
||||
@@ -36,7 +36,7 @@ class SandboxConfig:
|
||||
|
||||
remote_runtime_api_url: str = 'http://localhost:8000'
|
||||
local_runtime_url: str = 'http://localhost'
|
||||
keep_remote_runtime_alive: bool = True
|
||||
keep_runtime_alive: bool = True
|
||||
api_key: str | None = None
|
||||
base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22' # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
|
||||
runtime_container_image: str | None = None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -45,6 +46,29 @@ LOG_COLORS: Mapping[str, ColorType] = {
|
||||
}
|
||||
|
||||
|
||||
class NoColorFormatter(logging.Formatter):
|
||||
"""Formatter for non-colored logging in files."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# Create a deep copy of the record to avoid modifying the original
|
||||
new_record: logging.LogRecord = copy.deepcopy(record)
|
||||
# Strip ANSI color codes from the message
|
||||
new_record.msg = strip_ansi(new_record.msg)
|
||||
|
||||
return super().format(new_record)
|
||||
|
||||
|
||||
def strip_ansi(s: str) -> str:
|
||||
"""
|
||||
Removes ANSI escape sequences from str, as defined by ECMA-048 in
|
||||
http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-048.pdf
|
||||
# https://github.com/ewen-lbh/python-strip-ansi/blob/master/strip_ansi/__init__.py
|
||||
"""
|
||||
pattern = re.compile(r'\x1B\[\d+(;\d+){0,2}m')
|
||||
stripped = pattern.sub('', s)
|
||||
return stripped
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
msg_type = record.__dict__.get('msg_type')
|
||||
@@ -70,7 +94,7 @@ class ColoredFormatter(logging.Formatter):
|
||||
return super().format(record)
|
||||
|
||||
|
||||
file_formatter = logging.Formatter(
|
||||
file_formatter = NoColorFormatter(
|
||||
'%(asctime)s - %(name)s:%(levelname)s: %(filename)s:%(lineno)s - %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
)
|
||||
|
||||
@@ -54,11 +54,14 @@ def read_task_from_stdin() -> str:
|
||||
def create_runtime(
|
||||
config: AppConfig,
|
||||
sid: str | None = None,
|
||||
headless_mode: bool = True,
|
||||
) -> Runtime:
|
||||
"""Create a runtime for the agent to run on.
|
||||
|
||||
config: The app config.
|
||||
sid: The session id.
|
||||
headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts,
|
||||
where we don't want to have the VSCode UI open, so it defaults to True.
|
||||
"""
|
||||
# if sid is provided on the command line, use it as the name of the event stream
|
||||
# otherwise generate it on the basis of the configured jwt_secret
|
||||
@@ -80,6 +83,7 @@ def create_runtime(
|
||||
event_stream=event_stream,
|
||||
sid=session_id,
|
||||
plugins=agent_cls.sandbox_plugins,
|
||||
headless_mode=headless_mode,
|
||||
)
|
||||
|
||||
return runtime
|
||||
@@ -122,7 +126,7 @@ async def run_controller(
|
||||
sid = sid or generate_sid(config)
|
||||
|
||||
if runtime is None:
|
||||
runtime = create_runtime(config, sid=sid)
|
||||
runtime = create_runtime(config, sid=sid, headless_mode=headless_mode)
|
||||
await runtime.connect()
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
|
||||
@@ -98,6 +98,13 @@ class Message(BaseModel):
|
||||
content.extend(d)
|
||||
|
||||
ret: dict = {'content': content, 'role': self.role}
|
||||
# pop content if it's empty
|
||||
if not content or (
|
||||
len(content) == 1
|
||||
and content[0]['type'] == 'text'
|
||||
and content[0]['text'] == ''
|
||||
):
|
||||
ret.pop('content')
|
||||
|
||||
if role_tool_with_prompt_caching:
|
||||
ret['cache_control'] = {'type': 'ephemeral'}
|
||||
|
||||
@@ -7,7 +7,7 @@ from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
@dataclass
|
||||
class MessageAction(Action):
|
||||
content: str
|
||||
images_urls: list[str] | None = None
|
||||
image_urls: list[str] | None = None
|
||||
wait_for_response: bool = False
|
||||
action: str = ActionType.MESSAGE
|
||||
security_risk: ActionSecurityRisk | None = None
|
||||
@@ -16,10 +16,18 @@ class MessageAction(Action):
|
||||
def message(self) -> str:
|
||||
return self.content
|
||||
|
||||
@property
|
||||
def images_urls(self):
|
||||
# Deprecated alias for backward compatibility
|
||||
return self.image_urls
|
||||
|
||||
@images_urls.setter
|
||||
def images_urls(self, value):
|
||||
self.image_urls = value
|
||||
def __str__(self) -> str:
|
||||
ret = f'**MessageAction** (source={self.source})\n'
|
||||
ret += f'CONTENT: {self.content}'
|
||||
if self.images_urls:
|
||||
for url in self.images_urls:
|
||||
if self.image_urls:
|
||||
for url in self.image_urls:
|
||||
ret += f'\nIMAGE_URL: {url}'
|
||||
return ret
|
||||
|
||||
@@ -66,6 +66,10 @@ def action_from_dict(action: dict) -> Action:
|
||||
if is_confirmed is not None:
|
||||
args['confirmation_state'] = is_confirmed
|
||||
|
||||
# images_urls has been renamed to image_urls
|
||||
if 'images_urls' in args:
|
||||
args['image_urls'] = args.pop('images_urls')
|
||||
|
||||
try:
|
||||
decoded_action = action_class(**args)
|
||||
if 'timeout' in action:
|
||||
|
||||
@@ -101,7 +101,7 @@ def event_to_memory(event: 'Event', max_message_chars: int) -> dict:
|
||||
d.pop('cause', None)
|
||||
d.pop('timestamp', None)
|
||||
d.pop('message', None)
|
||||
d.pop('images_urls', None)
|
||||
d.pop('image_urls', None)
|
||||
|
||||
# runnable actions have some extra fields used in the BE/FE, which should not be sent to the LLM
|
||||
if 'args' in d:
|
||||
|
||||
@@ -9,9 +9,9 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.utils import json
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.events.serialization.event import event_from_dict, event_to_dict
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.storage import FileStore
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
class EventStreamSubscriber(str, Enum):
|
||||
|
||||
@@ -7,7 +7,7 @@ from litellm import acompletion as litellm_acompletion
|
||||
from openhands.core.exceptions import UserCancelledError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.llm import LLM, LLM_RETRY_EXCEPTIONS
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
class AsyncLLM(LLM):
|
||||
|
||||
@@ -14,7 +14,9 @@ class DebugMixin:
|
||||
|
||||
messages = messages if isinstance(messages, list) else [messages]
|
||||
debug_message = MESSAGE_SEPARATOR.join(
|
||||
self._format_message_content(msg) for msg in messages if msg['content']
|
||||
self._format_message_content(msg)
|
||||
for msg in messages
|
||||
if msg.get('content', None)
|
||||
)
|
||||
|
||||
if debug_message:
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.impl.e2b.sandbox import E2BBox
|
||||
from openhands.runtime.impl.eventstream.eventstream_runtime import (
|
||||
EventStreamRuntime,
|
||||
)
|
||||
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
|
||||
|
||||
def get_runtime_cls(name: str):
|
||||
# Local imports to avoid circular imports
|
||||
if name == 'eventstream':
|
||||
from openhands.runtime.impl.eventstream.eventstream_runtime import (
|
||||
EventStreamRuntime,
|
||||
)
|
||||
|
||||
return EventStreamRuntime
|
||||
elif name == 'e2b':
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
|
||||
return E2BRuntime
|
||||
return E2BBox
|
||||
elif name == 'remote':
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
|
||||
return RemoteRuntime
|
||||
elif name == 'modal':
|
||||
logger.debug('Using ModalRuntime')
|
||||
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')
|
||||
@@ -33,5 +27,9 @@ def get_runtime_cls(name: str):
|
||||
|
||||
__all__ = [
|
||||
'E2BBox',
|
||||
'RemoteRuntime',
|
||||
'ModalRuntime',
|
||||
'RunloopRuntime',
|
||||
'EventStreamRuntime',
|
||||
'get_runtime_cls',
|
||||
]
|
||||
|
||||
@@ -47,14 +47,11 @@ from openhands.events.observation import (
|
||||
from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
from openhands.runtime.browser import browse
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
from openhands.runtime.plugins import (
|
||||
ALL_PLUGINS,
|
||||
JupyterPlugin,
|
||||
Plugin,
|
||||
)
|
||||
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
|
||||
from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.async_utils import wait_all
|
||||
|
||||
|
||||
@@ -116,7 +113,10 @@ class ActionExecutor:
|
||||
return self._initial_pwd
|
||||
|
||||
async def ainit(self):
|
||||
await wait_all(self._init_plugin(plugin) for plugin in self.plugins_to_load)
|
||||
await wait_all(
|
||||
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# This is a temporary workaround
|
||||
# TODO: refactor AgentSkills to be part of JupyterPlugin
|
||||
@@ -345,6 +345,8 @@ if __name__ == '__main__':
|
||||
)
|
||||
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
|
||||
args = parser.parse_args()
|
||||
os.environ['VSCODE_PORT'] = str(int(args.port) + 1)
|
||||
assert check_port_available(int(os.environ['VSCODE_PORT']))
|
||||
|
||||
plugins_to_load: list[Plugin] = []
|
||||
if args.plugins:
|
||||
@@ -527,6 +529,19 @@ if __name__ == '__main__':
|
||||
async def alive():
|
||||
return {'status': 'ok'}
|
||||
|
||||
# ================================
|
||||
# VSCode-specific operations
|
||||
# ================================
|
||||
|
||||
@app.get('/vscode/connection_token')
|
||||
async def get_vscode_connection_token():
|
||||
assert client is not None
|
||||
if 'vscode' in client.plugins:
|
||||
plugin: VSCodePlugin = client.plugins['vscode'] # type: ignore
|
||||
return {'token': plugin.vscode_connection_token}
|
||||
else:
|
||||
return {'token': None}
|
||||
|
||||
# ================================
|
||||
# File-specific operations for UI
|
||||
# ================================
|
||||
|
||||
@@ -30,7 +30,11 @@ from openhands.events.observation import (
|
||||
UserRejectObservation,
|
||||
)
|
||||
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.runtime.plugins import JupyterRequirement, PluginRequirement
|
||||
from openhands.runtime.plugins import (
|
||||
JupyterRequirement,
|
||||
PluginRequirement,
|
||||
VSCodeRequirement,
|
||||
)
|
||||
from openhands.runtime.utils.edit import FileEditRuntimeMixin
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
@@ -84,13 +88,20 @@ class Runtime(FileEditRuntimeMixin):
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = False,
|
||||
):
|
||||
self.sid = sid
|
||||
self.event_stream = event_stream
|
||||
self.event_stream.subscribe(
|
||||
EventStreamSubscriber.RUNTIME, self.on_event, self.sid
|
||||
)
|
||||
self.plugins = plugins if plugins is not None and len(plugins) > 0 else []
|
||||
self.plugins = (
|
||||
copy.deepcopy(plugins) if plugins is not None and len(plugins) > 0 else []
|
||||
)
|
||||
# add VSCode plugin if not in headless mode
|
||||
if not headless_mode:
|
||||
self.plugins.append(VSCodeRequirement())
|
||||
|
||||
self.status_callback = status_callback
|
||||
self.attach_to_existing = attach_to_existing
|
||||
|
||||
@@ -101,6 +112,10 @@ class Runtime(FileEditRuntimeMixin):
|
||||
if env_vars is not None:
|
||||
self.initial_env_vars.update(env_vars)
|
||||
|
||||
self._vscode_enabled = any(
|
||||
isinstance(plugin, VSCodeRequirement) for plugin in self.plugins
|
||||
)
|
||||
|
||||
# Load mixins
|
||||
FileEditRuntimeMixin.__init__(self)
|
||||
|
||||
@@ -278,3 +293,15 @@ class Runtime(FileEditRuntimeMixin):
|
||||
def copy_from(self, path: str) -> Path:
|
||||
"""Zip all files in the sandbox and return a path in the local filesystem."""
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
# ====================================================================
|
||||
# VSCode
|
||||
# ====================================================================
|
||||
|
||||
@property
|
||||
def vscode_enabled(self) -> bool:
|
||||
return self._vscode_enabled
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@@ -16,7 +16,7 @@ from PIL import Image
|
||||
|
||||
from openhands.core.exceptions import BrowserInitException
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue, should_exit
|
||||
from openhands.utils.shutdown_listener import should_continue, should_exit
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
BROWSER_EVAL_GET_GOAL_ACTION = 'GET_EVAL_GOAL'
|
||||
|
||||
@@ -8,7 +8,7 @@ import requests
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.builder import RuntimeBuilder
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.runtime.utils.shutdown_listener import (
|
||||
from openhands.utils.shutdown_listener import (
|
||||
should_continue,
|
||||
sleep_if_should_continue,
|
||||
)
|
||||
|
||||
18
openhands/runtime/impl/eventstream/containers.py
Normal file
18
openhands/runtime/impl/eventstream/containers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import docker
|
||||
|
||||
|
||||
def remove_all_containers(prefix: str):
|
||||
docker_client = docker.from_env()
|
||||
|
||||
try:
|
||||
containers = docker_client.containers.list(all=True)
|
||||
for container in containers:
|
||||
try:
|
||||
if container.name.startswith(prefix):
|
||||
container.remove(force=True)
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
except docker.errors.NotFound: # yes, this can happen!
|
||||
pass
|
||||
@@ -1,8 +1,9 @@
|
||||
import atexit
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from zipfile import ZipFile
|
||||
|
||||
@@ -35,6 +36,7 @@ from openhands.events.serialization import event_to_dict, observation_from_dict
|
||||
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder
|
||||
from openhands.runtime.impl.eventstream.containers import remove_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.request import send_request
|
||||
@@ -42,6 +44,15 @@ from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
|
||||
def remove_all_runtime_containers():
|
||||
remove_all_containers(CONTAINER_NAME_PREFIX)
|
||||
|
||||
|
||||
atexit.register(remove_all_runtime_containers)
|
||||
|
||||
|
||||
class LogBuffer:
|
||||
"""Synchronous buffer for Docker container logs.
|
||||
@@ -114,8 +125,6 @@ class EventStreamRuntime(Runtime):
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
container_name_prefix = 'openhands-runtime-'
|
||||
|
||||
# Need to provide this method to allow inheritors to init the Runtime
|
||||
# without initting the EventStreamRuntime.
|
||||
def init_base_runtime(
|
||||
@@ -127,6 +136,7 @@ class EventStreamRuntime(Runtime):
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
super().__init__(
|
||||
config,
|
||||
@@ -136,6 +146,7 @@ class EventStreamRuntime(Runtime):
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -147,10 +158,13 @@ class EventStreamRuntime(Runtime):
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
self.config = config
|
||||
self._host_port = 30000 # initial dummy value
|
||||
self._container_port = 30001 # initial dummy value
|
||||
self._vscode_url: str | None = None # initial dummy value
|
||||
self._runtime_initialized: bool = False
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
self.session = requests.Session()
|
||||
self.status_callback = status_callback
|
||||
@@ -158,7 +172,7 @@ class EventStreamRuntime(Runtime):
|
||||
self.docker_client: docker.DockerClient = self._init_docker_client()
|
||||
self.base_container_image = self.config.sandbox.base_container_image
|
||||
self.runtime_container_image = self.config.sandbox.runtime_container_image
|
||||
self.container_name = self.container_name_prefix + sid
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.container = None
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
|
||||
@@ -173,10 +187,6 @@ class EventStreamRuntime(Runtime):
|
||||
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
||||
)
|
||||
|
||||
self.skip_container_logs = (
|
||||
os.environ.get('SKIP_CONTAINER_LOGS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
self.init_base_runtime(
|
||||
config,
|
||||
event_stream,
|
||||
@@ -185,11 +195,20 @@ class EventStreamRuntime(Runtime):
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
if not self.attach_to_existing:
|
||||
try:
|
||||
await call_sync_from_async(self._attach_to_container)
|
||||
except docker.errors.NotFound as e:
|
||||
if self.attach_to_existing:
|
||||
self.log(
|
||||
'error',
|
||||
f'Container {self.container_name} not found.',
|
||||
)
|
||||
raise e
|
||||
if self.runtime_container_image is None:
|
||||
if self.base_container_image is None:
|
||||
raise ValueError(
|
||||
@@ -208,15 +227,17 @@ class EventStreamRuntime(Runtime):
|
||||
'info', f'Starting runtime with image: {self.runtime_container_image}'
|
||||
)
|
||||
await call_sync_from_async(self._init_container)
|
||||
self.log('info', f'Container started: {self.container_name}')
|
||||
|
||||
else:
|
||||
await call_sync_from_async(self._attach_to_container)
|
||||
self.log(
|
||||
'info',
|
||||
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
|
||||
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
||||
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
||||
|
||||
await call_sync_from_async(self._wait_until_alive)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Runtime is ready.')
|
||||
|
||||
@@ -225,9 +246,11 @@ class EventStreamRuntime(Runtime):
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
self.send_status_message(' ')
|
||||
if not self.attach_to_existing:
|
||||
self.send_status_message(' ')
|
||||
self._runtime_initialized = True
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -248,7 +271,6 @@ class EventStreamRuntime(Runtime):
|
||||
plugin_arg = (
|
||||
f'--plugins {" ".join([plugin.name for plugin in self.plugins])} '
|
||||
)
|
||||
|
||||
self._host_port = self._find_available_port()
|
||||
self._container_port = (
|
||||
self._host_port
|
||||
@@ -257,6 +279,7 @@ class EventStreamRuntime(Runtime):
|
||||
|
||||
use_host_network = self.config.sandbox.use_host_network
|
||||
network_mode: str | None = 'host' if use_host_network else None
|
||||
|
||||
port_mapping: dict[str, list[dict[str, str]]] | None = (
|
||||
None
|
||||
if use_host_network
|
||||
@@ -277,6 +300,13 @@ class EventStreamRuntime(Runtime):
|
||||
if self.config.debug or DEBUG:
|
||||
environment['DEBUG'] = 'true'
|
||||
|
||||
if self.vscode_enabled:
|
||||
# vscode is on port +1 from container port
|
||||
if isinstance(port_mapping, dict):
|
||||
port_mapping[f'{self._container_port + 1}/tcp'] = [
|
||||
{'HostPort': str(self._host_port + 1)}
|
||||
]
|
||||
|
||||
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
|
||||
if (
|
||||
self.config.workspace_mount_path is not None
|
||||
@@ -332,13 +362,12 @@ class EventStreamRuntime(Runtime):
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
self.send_status_message('STATUS$CONTAINER_STARTED')
|
||||
except docker.errors.APIError as e:
|
||||
# check 409 error
|
||||
if '409' in str(e):
|
||||
self.log(
|
||||
'warning',
|
||||
f'Container {self.container_name} already exists. Removing...',
|
||||
)
|
||||
self._close_containers(rm_all_containers=True)
|
||||
remove_all_containers(self.container_name)
|
||||
return self._init_container()
|
||||
|
||||
else:
|
||||
@@ -414,42 +443,18 @@ class EventStreamRuntime(Runtime):
|
||||
Parameters:
|
||||
- rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
|
||||
"""
|
||||
|
||||
if self.log_buffer:
|
||||
self.log_buffer.close()
|
||||
|
||||
if self.session:
|
||||
self.session.close()
|
||||
|
||||
if self.attach_to_existing:
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
return
|
||||
self._close_containers(rm_all_containers)
|
||||
|
||||
def _close_containers(self, rm_all_containers: bool = True):
|
||||
try:
|
||||
containers = self.docker_client.containers.list(all=True)
|
||||
for container in containers:
|
||||
try:
|
||||
# If the app doesn't shut down properly, it can leave runtime containers on the system. This ensures
|
||||
# that all 'openhands-sandbox-' containers are removed as well.
|
||||
if rm_all_containers and container.name.startswith(
|
||||
self.container_name_prefix
|
||||
):
|
||||
container.remove(force=True)
|
||||
elif container.name == self.container_name:
|
||||
if not self.skip_container_logs:
|
||||
logs = container.logs(tail=1000).decode('utf-8')
|
||||
self.log(
|
||||
'debug',
|
||||
f'==== Container logs on close ====\n{logs}\n==== End of container logs ====',
|
||||
)
|
||||
container.remove(force=True)
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
except docker.errors.NotFound: # yes, this can happen!
|
||||
pass
|
||||
close_prefix = (
|
||||
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
|
||||
)
|
||||
remove_all_containers(close_prefix)
|
||||
|
||||
def run_action(self, action: Action) -> Observation:
|
||||
if isinstance(action, FileEditAction):
|
||||
@@ -642,3 +647,30 @@ class EventStreamRuntime(Runtime):
|
||||
return port
|
||||
# If no port is found after max_attempts, return the last tried port
|
||||
return port
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
if self.vscode_enabled and self._runtime_initialized:
|
||||
if (
|
||||
hasattr(self, '_vscode_url') and self._vscode_url is not None
|
||||
): # cached value
|
||||
return self._vscode_url
|
||||
|
||||
response = send_request(
|
||||
self.session,
|
||||
'GET',
|
||||
f'{self.api_url}/vscode/connection_token',
|
||||
timeout=10,
|
||||
)
|
||||
response_json = response.json()
|
||||
assert isinstance(response_json, dict)
|
||||
if response_json['token'] is None:
|
||||
return None
|
||||
self._vscode_url = f'http://localhost:{self._host_port + 1}/?tkn={response_json["token"]}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {self._vscode_url}',
|
||||
)
|
||||
return self._vscode_url
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -77,6 +77,7 @@ class ModalRuntime(EventStreamRuntime):
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
assert config.modal_api_token_id, 'Modal API token id is required'
|
||||
assert config.modal_api_token_secret, 'Modal API token secret is required'
|
||||
@@ -124,6 +125,7 @@ class ModalRuntime(EventStreamRuntime):
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlparse
|
||||
from zipfile import ZipFile
|
||||
|
||||
import requests
|
||||
@@ -57,6 +58,7 @@ class RemoteRuntime(Runtime):
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Optional[Callable] = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
# We need to set session and action_semaphore before the __init__ below, or we get odd errors
|
||||
self.session = requests.Session()
|
||||
@@ -70,6 +72,7 @@ class RemoteRuntime(Runtime):
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
)
|
||||
if self.config.sandbox.api_key is None:
|
||||
raise ValueError(
|
||||
@@ -89,6 +92,8 @@ class RemoteRuntime(Runtime):
|
||||
)
|
||||
self.runtime_id: str | None = None
|
||||
self.runtime_url: str | None = None
|
||||
self._runtime_initialized: bool = False
|
||||
self._vscode_url: str | None = None # initial dummy value
|
||||
|
||||
async def connect(self):
|
||||
try:
|
||||
@@ -97,6 +102,7 @@ class RemoteRuntime(Runtime):
|
||||
self.log('error', 'Runtime failed to start, timed out before ready')
|
||||
raise
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
self._runtime_initialized = True
|
||||
|
||||
def _start_or_attach_to_runtime(self):
|
||||
existing_runtime = self._check_existing_runtime()
|
||||
@@ -138,6 +144,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
is_retry=False,
|
||||
timeout=5,
|
||||
)
|
||||
except requests.HTTPError as e:
|
||||
@@ -168,6 +175,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
|
||||
is_retry=False,
|
||||
timeout=10,
|
||||
)
|
||||
response_json = response.json()
|
||||
@@ -198,6 +206,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
|
||||
is_retry=False,
|
||||
params={'image': self.container_image},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -234,6 +243,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/start',
|
||||
is_retry=False,
|
||||
json=start_request,
|
||||
)
|
||||
self._parse_runtime_response(response)
|
||||
@@ -246,6 +256,7 @@ class RemoteRuntime(Runtime):
|
||||
self._send_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
is_retry=False,
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=30,
|
||||
)
|
||||
@@ -260,6 +271,43 @@ class RemoteRuntime(Runtime):
|
||||
{'X-Session-API-Key': start_response['session_api_key']}
|
||||
)
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
if self.vscode_enabled and self._runtime_initialized:
|
||||
if (
|
||||
hasattr(self, '_vscode_url') and self._vscode_url is not None
|
||||
): # cached value
|
||||
return self._vscode_url
|
||||
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.runtime_url}/vscode/connection_token',
|
||||
timeout=10,
|
||||
)
|
||||
response_json = response.json()
|
||||
assert isinstance(response_json, dict)
|
||||
if response_json['token'] is None:
|
||||
return None
|
||||
# parse runtime_url to get vscode_url
|
||||
_parsed_url = urlparse(self.runtime_url)
|
||||
assert isinstance(_parsed_url.scheme, str) and isinstance(
|
||||
_parsed_url.netloc, str
|
||||
)
|
||||
self._vscode_url = f'{_parsed_url.scheme}://vscode-{_parsed_url.netloc}/?tkn={response_json["token"]}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {self._vscode_url}',
|
||||
)
|
||||
return self._vscode_url
|
||||
else:
|
||||
return None
|
||||
|
||||
@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(
|
||||
@@ -283,14 +331,11 @@ class RemoteRuntime(Runtime):
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
pod_status = runtime_data['pod_status']
|
||||
self.log('debug', f'Pod status: {pod_status}')
|
||||
|
||||
# FIXME: We should fix it at the backend of /start endpoint, make sure
|
||||
# the pod is created before returning the response.
|
||||
# Retry a period of time to give the cluster time to start the pod
|
||||
if pod_status == 'Not Found':
|
||||
raise RuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
if pod_status == 'Ready':
|
||||
try:
|
||||
self._send_request(
|
||||
@@ -305,12 +350,23 @@ class RemoteRuntime(Runtime):
|
||||
f'Runtime /alive failed to respond with 200: {e}'
|
||||
)
|
||||
return
|
||||
if pod_status in ('Failed', 'Unknown'):
|
||||
elif (
|
||||
pod_status == 'Not Found'
|
||||
or pod_status == 'Pending'
|
||||
or pod_status == 'Running'
|
||||
): # nb: Running is not yet Ready
|
||||
raise RuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
elif pod_status in ('Failed', 'Unknown'):
|
||||
# clean up the runtime
|
||||
self.close()
|
||||
raise RuntimeError(
|
||||
f'Runtime (ID={self.runtime_id}) failed to start. Current status: {pod_status}'
|
||||
)
|
||||
else:
|
||||
# Maybe this should be a hard failure, but passing through in case the API changes
|
||||
self.log('warning', f'Unknown pod status: {pod_status}')
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
@@ -319,7 +375,7 @@ class RemoteRuntime(Runtime):
|
||||
raise RuntimeNotReadyError()
|
||||
|
||||
def close(self, timeout: int = 10):
|
||||
if self.config.sandbox.keep_remote_runtime_alive or self.attach_to_existing:
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
self.session.close()
|
||||
return
|
||||
if self.runtime_id and self.session:
|
||||
@@ -327,6 +383,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
is_retry=False,
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -342,7 +399,7 @@ class RemoteRuntime(Runtime):
|
||||
finally:
|
||||
self.session.close()
|
||||
|
||||
def run_action(self, action: Action) -> Observation:
|
||||
def run_action(self, action: Action, is_retry: bool = False) -> Observation:
|
||||
if action.timeout is None:
|
||||
action.timeout = self.config.sandbox.timeout
|
||||
if isinstance(action, FileEditAction):
|
||||
@@ -367,6 +424,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.runtime_url}/execute_action',
|
||||
is_retry=False,
|
||||
json=request_body,
|
||||
# wait a few more seconds to get the timeout error from client side
|
||||
timeout=action.timeout + 5,
|
||||
@@ -380,7 +438,7 @@ class RemoteRuntime(Runtime):
|
||||
)
|
||||
return obs
|
||||
|
||||
def _send_request(self, method, url, **kwargs):
|
||||
def _send_request(self, method, url, is_retry=False, **kwargs):
|
||||
is_runtime_request = self.runtime_url and self.runtime_url in url
|
||||
try:
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
@@ -392,6 +450,15 @@ class RemoteRuntime(Runtime):
|
||||
raise RuntimeDisconnectedError(
|
||||
f'404 error while connecting to {self.runtime_url}'
|
||||
)
|
||||
elif is_runtime_request and e.response.status_code == 503:
|
||||
if not is_retry:
|
||||
self.log('warning', 'Runtime appears to be paused. Resuming...')
|
||||
self._resume_runtime()
|
||||
self._wait_until_alive()
|
||||
return self._send_request(method, url, True, **kwargs)
|
||||
else:
|
||||
raise e
|
||||
|
||||
else:
|
||||
raise e
|
||||
|
||||
@@ -444,6 +511,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.runtime_url}/upload_file',
|
||||
is_retry=False,
|
||||
files=upload_data,
|
||||
params=params,
|
||||
timeout=300,
|
||||
@@ -467,6 +535,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.runtime_url}/list_files',
|
||||
is_retry=False,
|
||||
json=data,
|
||||
timeout=30,
|
||||
)
|
||||
@@ -480,6 +549,7 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.runtime_url}/download_files',
|
||||
is_retry=False,
|
||||
params=params,
|
||||
stream=True,
|
||||
timeout=30,
|
||||
|
||||
@@ -21,6 +21,8 @@ from openhands.runtime.utils.command import get_remote_startup_command
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
|
||||
class RunloopLogBuffer(LogBuffer):
|
||||
"""Synchronous buffer for Runloop devbox logs.
|
||||
@@ -115,7 +117,7 @@ class RunloopRuntime(EventStreamRuntime):
|
||||
bearer_token=config.runloop_api_key,
|
||||
)
|
||||
self.session = requests.Session()
|
||||
self.container_name = self.container_name_prefix + sid
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
self.init_base_runtime(
|
||||
config,
|
||||
@@ -190,7 +192,7 @@ class RunloopRuntime(EventStreamRuntime):
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
available_ports=[self._sandbox_port],
|
||||
resource_size_request="LARGE",
|
||||
resource_size_request='LARGE',
|
||||
),
|
||||
metadata={'container-name': self.container_name},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from openhands.runtime.plugins.agent_skills import (
|
||||
)
|
||||
from openhands.runtime.plugins.jupyter import JupyterPlugin, JupyterRequirement
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.plugins.vscode import VSCodePlugin, VSCodeRequirement
|
||||
|
||||
__all__ = [
|
||||
'Plugin',
|
||||
@@ -13,9 +14,12 @@ __all__ = [
|
||||
'AgentSkillsPlugin',
|
||||
'JupyterRequirement',
|
||||
'JupyterPlugin',
|
||||
'VSCodeRequirement',
|
||||
'VSCodePlugin',
|
||||
]
|
||||
|
||||
ALL_PLUGINS = {
|
||||
'jupyter': JupyterPlugin,
|
||||
'agent_skills': AgentSkillsPlugin,
|
||||
'vscode': VSCodePlugin,
|
||||
}
|
||||
|
||||
@@ -1,60 +1,8 @@
|
||||
"""This file contains a global singleton of the `EditTool` class as well as raw functions that expose its __call__."""
|
||||
|
||||
from .base import CLIResult, ToolError, ToolResult
|
||||
from .impl import Command, EditTool
|
||||
|
||||
_GLOBAL_EDITOR = EditTool()
|
||||
|
||||
|
||||
def _make_api_tool_result(
|
||||
result: ToolResult,
|
||||
) -> str:
|
||||
"""Convert an agent ToolResult to an API ToolResultBlockParam."""
|
||||
tool_result_content: str = ''
|
||||
is_error = False
|
||||
if result.error:
|
||||
is_error = True
|
||||
tool_result_content = _maybe_prepend_system_tool_result(result, result.error)
|
||||
else:
|
||||
assert result.output, 'Expecting output in file_editor'
|
||||
tool_result_content = _maybe_prepend_system_tool_result(result, result.output)
|
||||
assert (
|
||||
not result.base64_image
|
||||
), 'Not expecting base64_image as output in file_editor'
|
||||
if is_error:
|
||||
return f'ERROR:\n{tool_result_content}'
|
||||
else:
|
||||
return tool_result_content
|
||||
|
||||
|
||||
def _maybe_prepend_system_tool_result(result: ToolResult, result_text: str) -> str:
|
||||
if result.system:
|
||||
result_text = f'<system>{result.system}</system>\n{result_text}'
|
||||
return result_text
|
||||
|
||||
|
||||
def file_editor(
|
||||
command: Command,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
result: CLIResult = _GLOBAL_EDITOR(
|
||||
command=command,
|
||||
path=path,
|
||||
file_text=file_text,
|
||||
view_range=view_range,
|
||||
old_str=old_str,
|
||||
new_str=new_str,
|
||||
insert_line=insert_line,
|
||||
)
|
||||
except ToolError as e:
|
||||
return _make_api_tool_result(ToolResult(error=e.message))
|
||||
return _make_api_tool_result(result)
|
||||
"""This file imports a global singleton of the `EditTool` class as well as raw functions that expose
|
||||
its __call__.
|
||||
The implementation of the `EditTool` class can be found at: https://github.com/All-Hands-AI/openhands-aci/.
|
||||
"""
|
||||
|
||||
from openhands_aci.editor import file_editor
|
||||
|
||||
__all__ = ['file_editor']
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
from dataclasses import dataclass, fields, replace
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
"""Represents the result of a tool execution."""
|
||||
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
|
||||
def __bool__(self):
|
||||
return any(getattr(self, field.name) for field in fields(self))
|
||||
|
||||
def __add__(self, other: 'ToolResult'):
|
||||
def combine_fields(
|
||||
field: str | None, other_field: str | None, concatenate: bool = True
|
||||
):
|
||||
if field and other_field:
|
||||
if concatenate:
|
||||
return field + other_field
|
||||
raise ValueError('Cannot combine tool results')
|
||||
return field or other_field
|
||||
|
||||
return ToolResult(
|
||||
output=combine_fields(self.output, other.output),
|
||||
error=combine_fields(self.error, other.error),
|
||||
base64_image=combine_fields(self.base64_image, other.base64_image, False),
|
||||
system=combine_fields(self.system, other.system),
|
||||
)
|
||||
|
||||
def replace(self, **kwargs):
|
||||
"""Returns a new ToolResult with the given fields replaced."""
|
||||
return replace(self, **kwargs)
|
||||
|
||||
|
||||
class CLIResult(ToolResult):
|
||||
"""A ToolResult that can be rendered as a CLI output."""
|
||||
|
||||
|
||||
class ToolFailure(ToolResult):
|
||||
"""A ToolResult that represents a failure."""
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""Raised when a tool encounters an error."""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
@@ -1,279 +0,0 @@
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Literal, get_args
|
||||
|
||||
from .base import CLIResult, ToolError, ToolResult
|
||||
from .run import maybe_truncate, run
|
||||
|
||||
Command = Literal[
|
||||
'view',
|
||||
'create',
|
||||
'str_replace',
|
||||
'insert',
|
||||
'undo_edit',
|
||||
]
|
||||
SNIPPET_LINES: int = 4
|
||||
|
||||
|
||||
class EditTool:
|
||||
"""
|
||||
An filesystem editor tool that allows the agent to view, create, and edit files.
|
||||
The tool parameters are defined by Anthropic and are not editable.
|
||||
|
||||
Original implementation: https://github.com/anthropics/anthropic-quickstarts/blob/main/computer-use-demo/computer_use_demo/tools/edit.py
|
||||
"""
|
||||
|
||||
_file_history: dict[Path, list[str]]
|
||||
|
||||
def __init__(self):
|
||||
self._file_history = defaultdict(list)
|
||||
super().__init__()
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
command: Command,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
_path = Path(path)
|
||||
self.validate_path(command, _path)
|
||||
if command == 'view':
|
||||
return self.view(_path, view_range)
|
||||
elif command == 'create':
|
||||
if file_text is None:
|
||||
raise ToolError('Parameter `file_text` is required for command: create')
|
||||
self.write_file(_path, file_text)
|
||||
self._file_history[_path].append(file_text)
|
||||
return ToolResult(output=f'File created successfully at: {_path}')
|
||||
elif command == 'str_replace':
|
||||
if old_str is None:
|
||||
raise ToolError(
|
||||
'Parameter `old_str` is required for command: str_replace'
|
||||
)
|
||||
return self.str_replace(_path, old_str, new_str)
|
||||
elif command == 'insert':
|
||||
if insert_line is None:
|
||||
raise ToolError(
|
||||
'Parameter `insert_line` is required for command: insert'
|
||||
)
|
||||
if new_str is None:
|
||||
raise ToolError('Parameter `new_str` is required for command: insert')
|
||||
return self.insert(_path, insert_line, new_str)
|
||||
elif command == 'undo_edit':
|
||||
return self.undo_edit(_path)
|
||||
raise ToolError(
|
||||
f'Unrecognized command {command}. The allowed commands for the {self.name} tool are: {", ".join(get_args(Command))}'
|
||||
)
|
||||
|
||||
def validate_path(self, command: str, path: Path):
|
||||
"""
|
||||
Check that the path/command combination is valid.
|
||||
"""
|
||||
# Check if its an absolute path
|
||||
if not path.is_absolute():
|
||||
suggested_path = Path('') / path
|
||||
raise ToolError(
|
||||
f'The path {path} is not an absolute path, it should start with `/`. Maybe you meant {suggested_path}?'
|
||||
)
|
||||
# Check if path exists
|
||||
if not path.exists() and command != 'create':
|
||||
raise ToolError(
|
||||
f'The path {path} does not exist. Please provide a valid path.'
|
||||
)
|
||||
if path.exists() and command == 'create':
|
||||
raise ToolError(
|
||||
f'File already exists at: {path}. Cannot overwrite files using command `create`.'
|
||||
)
|
||||
# Check if the path points to a directory
|
||||
if path.is_dir():
|
||||
if command != 'view':
|
||||
raise ToolError(
|
||||
f'The path {path} is a directory and only the `view` command can be used on directories'
|
||||
)
|
||||
|
||||
def view(self, path: Path, view_range: list[int] | None = None):
|
||||
"""Implement the view command"""
|
||||
if path.is_dir():
|
||||
if view_range:
|
||||
raise ToolError(
|
||||
'The `view_range` parameter is not allowed when `path` points to a directory.'
|
||||
)
|
||||
|
||||
_, stdout, stderr = run(rf"find {path} -maxdepth 2 -not -path '*/\.*'")
|
||||
if not stderr:
|
||||
stdout = f"Here's the files and directories up to 2 levels deep in {path}, excluding hidden items:\n{stdout}\n"
|
||||
return CLIResult(output=stdout, error=stderr)
|
||||
|
||||
file_content = self.read_file(path)
|
||||
init_line = 1
|
||||
if view_range:
|
||||
if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
|
||||
raise ToolError(
|
||||
'Invalid `view_range`. It should be a list of two integers.'
|
||||
)
|
||||
file_lines = file_content.split('\n')
|
||||
n_lines_file = len(file_lines)
|
||||
init_line, final_line = view_range
|
||||
if init_line < 1 or init_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. It's first element `{init_line}` should be within the range of lines of the file: {[1, n_lines_file]}"
|
||||
)
|
||||
if final_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be smaller than the number of lines in the file: `{n_lines_file}`"
|
||||
)
|
||||
if final_line != -1 and final_line < init_line:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be larger or equal than its first `{init_line}`"
|
||||
)
|
||||
|
||||
if final_line == -1:
|
||||
file_content = '\n'.join(file_lines[init_line - 1 :])
|
||||
else:
|
||||
file_content = '\n'.join(file_lines[init_line - 1 : final_line])
|
||||
|
||||
return CLIResult(
|
||||
output=self._make_output(file_content, str(path), init_line=init_line)
|
||||
)
|
||||
|
||||
def str_replace(self, path: Path, old_str: str, new_str: str | None):
|
||||
"""Implement the str_replace command, which replaces old_str with new_str in the file content"""
|
||||
# Read the file content
|
||||
file_content = self.read_file(path).expandtabs()
|
||||
old_str = old_str.expandtabs()
|
||||
new_str = new_str.expandtabs() if new_str is not None else ''
|
||||
|
||||
# Check if old_str is unique in the file
|
||||
occurrences = file_content.count(old_str)
|
||||
if occurrences == 0:
|
||||
raise ToolError(
|
||||
f'No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}.'
|
||||
)
|
||||
elif occurrences > 1:
|
||||
file_content_lines = file_content.split('\n')
|
||||
lines = [
|
||||
idx + 1
|
||||
for idx, line in enumerate(file_content_lines)
|
||||
if old_str in line
|
||||
]
|
||||
raise ToolError(
|
||||
f'No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines {lines}. Please ensure it is unique'
|
||||
)
|
||||
|
||||
# Replace old_str with new_str
|
||||
new_file_content = file_content.replace(old_str, new_str)
|
||||
|
||||
# Write the new content to the file
|
||||
self.write_file(path, new_file_content)
|
||||
|
||||
# Save the content to history
|
||||
self._file_history[path].append(file_content)
|
||||
|
||||
# Create a snippet of the edited section
|
||||
replacement_line = file_content.split(old_str)[0].count('\n')
|
||||
start_line = max(0, replacement_line - SNIPPET_LINES)
|
||||
end_line = replacement_line + SNIPPET_LINES + new_str.count('\n')
|
||||
snippet = '\n'.join(new_file_content.split('\n')[start_line : end_line + 1])
|
||||
|
||||
# Prepare the success message
|
||||
success_msg = f'The file {path} has been edited. '
|
||||
success_msg += self._make_output(
|
||||
snippet, f'a snippet of {path}', start_line + 1
|
||||
)
|
||||
success_msg += 'Review the changes and make sure they are as expected. Edit the file again if necessary.'
|
||||
|
||||
return CLIResult(output=success_msg)
|
||||
|
||||
def insert(self, path: Path, insert_line: int, new_str: str):
|
||||
"""Implement the insert command, which inserts new_str at the specified line in the file content."""
|
||||
file_text = self.read_file(path).expandtabs()
|
||||
new_str = new_str.expandtabs()
|
||||
file_text_lines = file_text.split('\n')
|
||||
n_lines_file = len(file_text_lines)
|
||||
|
||||
if insert_line < 0 or insert_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f'Invalid `insert_line` parameter: {insert_line}. It should be within the range of lines of the file: {[0, n_lines_file]}'
|
||||
)
|
||||
|
||||
new_str_lines = new_str.split('\n')
|
||||
new_file_text_lines = (
|
||||
file_text_lines[:insert_line]
|
||||
+ new_str_lines
|
||||
+ file_text_lines[insert_line:]
|
||||
)
|
||||
snippet_lines = (
|
||||
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
|
||||
+ new_str_lines
|
||||
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
|
||||
)
|
||||
|
||||
new_file_text = '\n'.join(new_file_text_lines)
|
||||
snippet = '\n'.join(snippet_lines)
|
||||
|
||||
self.write_file(path, new_file_text)
|
||||
self._file_history[path].append(file_text)
|
||||
|
||||
success_msg = f'The file {path} has been edited. '
|
||||
success_msg += self._make_output(
|
||||
snippet,
|
||||
'a snippet of the edited file',
|
||||
max(1, insert_line - SNIPPET_LINES + 1),
|
||||
)
|
||||
success_msg += 'Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary.'
|
||||
return CLIResult(output=success_msg)
|
||||
|
||||
def undo_edit(self, path: Path):
|
||||
"""Implement the undo_edit command."""
|
||||
if not self._file_history[path]:
|
||||
raise ToolError(f'No edit history found for {path}.')
|
||||
|
||||
old_text = self._file_history[path].pop()
|
||||
self.write_file(path, old_text)
|
||||
|
||||
return CLIResult(
|
||||
output=f'Last edit to {path} undone successfully. {self._make_output(old_text, str(path))}'
|
||||
)
|
||||
|
||||
def read_file(self, path: Path):
|
||||
"""Read the content of a file from a given path; raise a ToolError if an error occurs."""
|
||||
try:
|
||||
return path.read_text()
|
||||
except Exception as e:
|
||||
raise ToolError(f'Ran into {e} while trying to read {path}') from None
|
||||
|
||||
def write_file(self, path: Path, file: str):
|
||||
"""Write the content of a file to a given path; raise a ToolError if an error occurs."""
|
||||
try:
|
||||
path.write_text(file)
|
||||
except Exception as e:
|
||||
raise ToolError(f'Ran into {e} while trying to write to {path}') from None
|
||||
|
||||
def _make_output(
|
||||
self,
|
||||
file_content: str,
|
||||
file_descriptor: str,
|
||||
init_line: int = 1,
|
||||
expand_tabs: bool = True,
|
||||
):
|
||||
"""Generate output for the CLI based on the content of a file."""
|
||||
file_content = maybe_truncate(file_content)
|
||||
if expand_tabs:
|
||||
file_content = file_content.expandtabs()
|
||||
file_content = '\n'.join(
|
||||
[
|
||||
f'{i + init_line:6}\t{line}'
|
||||
for i, line in enumerate(file_content.split('\n'))
|
||||
]
|
||||
)
|
||||
return (
|
||||
f"Here's the result of running `cat -n` on {file_descriptor}:\n"
|
||||
+ file_content
|
||||
+ '\n'
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Utility to run shell commands asynchronously with a timeout."""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
TRUNCATED_MESSAGE: str = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
|
||||
MAX_RESPONSE_LEN: int = 16000
|
||||
|
||||
|
||||
def maybe_truncate(content: str, truncate_after: int | None = MAX_RESPONSE_LEN):
|
||||
"""Truncate content and append a notice if content exceeds the specified length."""
|
||||
return (
|
||||
content
|
||||
if not truncate_after or len(content) <= truncate_after
|
||||
else content[:truncate_after] + TRUNCATED_MESSAGE
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
cmd: str,
|
||||
timeout: float | None = 120.0, # seconds
|
||||
truncate_after: int | None = MAX_RESPONSE_LEN,
|
||||
):
|
||||
"""Run a shell command synchronously with a timeout."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
|
||||
return (
|
||||
process.returncode or 0,
|
||||
maybe_truncate(stdout, truncate_after=truncate_after),
|
||||
maybe_truncate(stderr, truncate_after=truncate_after),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
elapsed_time = time.time() - start_time
|
||||
raise TimeoutError(
|
||||
f"Command '{cmd}' timed out after {elapsed_time:.2f} seconds"
|
||||
)
|
||||
@@ -8,7 +8,7 @@ from openhands.events.observation import IPythonRunCellObservation
|
||||
from openhands.runtime.plugins.jupyter.execute_server import JupyterKernel
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
51
openhands/runtime/plugins/vscode/__init__.py
Normal file
51
openhands/runtime/plugins/vscode/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
@dataclass
|
||||
class VSCodeRequirement(PluginRequirement):
|
||||
name: str = 'vscode'
|
||||
|
||||
|
||||
class VSCodePlugin(Plugin):
|
||||
name: str = 'vscode'
|
||||
|
||||
async def initialize(self, username: str):
|
||||
self.vscode_port = int(os.environ['VSCODE_PORT'])
|
||||
self.vscode_connection_token = str(uuid.uuid4())
|
||||
assert check_port_available(self.vscode_port)
|
||||
cmd = (
|
||||
f"su - {username} -s /bin/bash << 'EOF'\n"
|
||||
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
|
||||
'cd /workspace\n'
|
||||
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port}\n'
|
||||
'EOF'
|
||||
)
|
||||
print(cmd)
|
||||
self.gateway_process = subprocess.Popen(
|
||||
cmd,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=True,
|
||||
)
|
||||
# read stdout until the kernel gateway is ready
|
||||
output = ''
|
||||
while should_continue() and self.gateway_process.stdout is not None:
|
||||
line = self.gateway_process.stdout.readline().decode('utf-8')
|
||||
print(line)
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
time.sleep(1)
|
||||
logger.debug('Waiting for VSCode server to start...')
|
||||
|
||||
logger.debug(
|
||||
f'VSCode server started at port {self.vscode_port}. Output: {output}'
|
||||
)
|
||||
@@ -1,8 +1,71 @@
|
||||
FROM {{ base_image }}
|
||||
|
||||
# Shared environment variables (regardless of init or not)
|
||||
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry
|
||||
ENV MAMBA_ROOT_PREFIX=/openhands/micromamba
|
||||
# Shared environment variables
|
||||
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
MAMBA_ROOT_PREFIX=/openhands/micromamba \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
EDITOR=code \
|
||||
VISUAL=code \
|
||||
GIT_EDITOR="code --wait" \
|
||||
OPENVSCODE_SERVER_ROOT=/openhands/.openvscode-server
|
||||
|
||||
{% macro setup_base_system() %}
|
||||
|
||||
# Install base system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl sudo apt-utils git \
|
||||
{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
|
||||
libgl1 \
|
||||
{% else %}
|
||||
libgl1-mesa-glx \
|
||||
{% endif %}
|
||||
libasound2-plugins libatomic1 curl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Remove UID 1000 if it's called pn--this fixes the nikolaik image for ubuntu users
|
||||
RUN if getent passwd 1000 | grep -q pn; then userdel pn; fi
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro setup_vscode_server() %}
|
||||
# Reference:
|
||||
# 1. https://github.com/gitpod-io/openvscode-server
|
||||
# 2. https://github.com/gitpod-io/openvscode-releases
|
||||
|
||||
# Setup VSCode Server
|
||||
ARG RELEASE_TAG="openvscode-server-v1.94.2"
|
||||
ARG RELEASE_ORG="gitpod-io"
|
||||
# ARG USERNAME=openvscode-server
|
||||
# ARG USER_UID=1000
|
||||
# ARG USER_GID=1000
|
||||
|
||||
RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
echo "The RELEASE_TAG build arg must be set." >&2 && \
|
||||
exit 1; \
|
||||
fi && \
|
||||
arch=$(uname -m) && \
|
||||
if [ "${arch}" = "x86_64" ]; then \
|
||||
arch="x64"; \
|
||||
elif [ "${arch}" = "aarch64" ]; then \
|
||||
arch="arm64"; \
|
||||
elif [ "${arch}" = "armv7l" ]; then \
|
||||
arch="armhf"; \
|
||||
fi && \
|
||||
wget https://github.com/${RELEASE_ORG}/openvscode-server/releases/download/${RELEASE_TAG}/${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
tar -xzf ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
mv -f ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
|
||||
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_dependencies() %}
|
||||
# Install all dependencies
|
||||
@@ -28,6 +91,7 @@ RUN \
|
||||
# Clean up
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
/openhands/micromamba/bin/micromamba clean --all
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% if build_from_scratch %}
|
||||
@@ -37,25 +101,8 @@ RUN \
|
||||
# This is used in cases where the base image is something more generic like nikolaik/python-nodejs
|
||||
# rather than the current OpenHands release
|
||||
|
||||
{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
|
||||
{% set LIBGL_MESA = 'libgl1' %}
|
||||
{% else %}
|
||||
{% set LIBGL_MESA = 'libgl1-mesa-glx' %}
|
||||
{% endif %}
|
||||
|
||||
# Install necessary packages and clean up in one layer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y wget curl sudo apt-utils {{ LIBGL_MESA }} libasound2-plugins git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Remove UID 1000 if it's called pn--this fixes the nikolaik image for ubuntu users
|
||||
RUN if getent passwd 1000 | grep -q pn; then userdel pn; fi
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry
|
||||
{{ setup_base_system() }}
|
||||
{{ setup_vscode_server() }}
|
||||
|
||||
# Install micromamba
|
||||
RUN mkdir -p /openhands/micromamba/bin && \
|
||||
@@ -72,6 +119,7 @@ RUN \
|
||||
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
|
||||
mkdir -p /openhands/code/openhands && \
|
||||
touch /openhands/code/openhands/__init__.py
|
||||
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
{{ install_dependencies() }}
|
||||
|
||||
@@ -3,6 +3,18 @@ import socket
|
||||
import time
|
||||
|
||||
|
||||
def check_port_available(port: int) -> bool:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(('localhost', port))
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.1) # Short delay to further reduce chance of collisions
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def find_available_tcp_port(min_port=30000, max_port=39999, max_attempts=10) -> int:
|
||||
"""Find an available TCP port in a specified range.
|
||||
|
||||
@@ -19,15 +31,8 @@ def find_available_tcp_port(min_port=30000, max_port=39999, max_attempts=10) ->
|
||||
rng.shuffle(ports)
|
||||
|
||||
for port in ports[:max_attempts]:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(('localhost', port))
|
||||
if check_port_available(port):
|
||||
return port
|
||||
except OSError:
|
||||
time.sleep(0.1) # Short delay to further reduce chance of collisions
|
||||
continue
|
||||
finally:
|
||||
sock.close()
|
||||
return -1
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from tenacity import RetryCallState
|
||||
from tenacity.stop import stop_base
|
||||
|
||||
from openhands.runtime.utils.shutdown_listener import should_exit
|
||||
from openhands.utils.shutdown_listener import should_exit
|
||||
|
||||
|
||||
class stop_if_should_exit(stop_base):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from github import Github
|
||||
from github.GithubException import GithubException
|
||||
@@ -14,7 +15,7 @@ GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
|
||||
|
||||
class UserVerifier:
|
||||
def __init__(self) -> None:
|
||||
logger.debug('Initializing UserVerifier')
|
||||
logger.info('Initializing UserVerifier')
|
||||
self.file_users: list[str] | None = None
|
||||
self.sheets_client: GoogleSheetsClient | None = None
|
||||
self.spreadsheet_id: str | None = None
|
||||
@@ -27,7 +28,7 @@ class UserVerifier:
|
||||
"""Load users from text file if configured"""
|
||||
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
|
||||
if not waitlist:
|
||||
logger.debug('GITHUB_USER_LIST_FILE not configured')
|
||||
logger.info('GITHUB_USER_LIST_FILE not configured')
|
||||
return
|
||||
|
||||
if not os.path.exists(waitlist):
|
||||
@@ -48,10 +49,10 @@ class UserVerifier:
|
||||
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
|
||||
|
||||
if not sheet_id:
|
||||
logger.debug('GITHUB_USERS_SHEET_ID not configured')
|
||||
logger.info('GITHUB_USERS_SHEET_ID not configured')
|
||||
return
|
||||
|
||||
logger.debug('Initializing Google Sheets integration')
|
||||
logger.info('Initializing Google Sheets integration')
|
||||
self.sheets_client = GoogleSheetsClient()
|
||||
self.spreadsheet_id = sheet_id
|
||||
|
||||
@@ -60,41 +61,50 @@ class UserVerifier:
|
||||
|
||||
def is_user_allowed(self, username: str) -> bool:
|
||||
"""Check if user is allowed based on file and/or sheet configuration"""
|
||||
start_time = time.time()
|
||||
if not self.is_active():
|
||||
return True
|
||||
|
||||
logger.debug(f'Checking if GitHub user {username} is allowed')
|
||||
logger.info(f'Checking if GitHub user {username} is allowed')
|
||||
if self.file_users:
|
||||
if username in self.file_users:
|
||||
logger.debug(f'User {username} found in text file allowlist')
|
||||
logger.info(f'User {username} found in text file allowlist')
|
||||
return True
|
||||
logger.debug(f'User {username} not found in text file allowlist')
|
||||
logger.info(f'User {username} not found in text file allowlist')
|
||||
|
||||
logger.info(f'Took {time.time() - start_time:.2f} seconds to check file users')
|
||||
|
||||
if self.sheets_client and self.spreadsheet_id:
|
||||
sheet_users = self.sheets_client.get_usernames(self.spreadsheet_id)
|
||||
if username in sheet_users:
|
||||
logger.debug(f'User {username} found in Google Sheets allowlist')
|
||||
logger.info(f'User {username} found in Google Sheets allowlist')
|
||||
return True
|
||||
logger.debug(f'User {username} not found in Google Sheets allowlist')
|
||||
logger.info(f'User {username} not found in Google Sheets allowlist')
|
||||
|
||||
logger.debug(f'User {username} not found in any allowlist')
|
||||
logger.info(f'Took {time.time() - start_time:.2f} seconds to check sheet users')
|
||||
|
||||
logger.info(f'User {username} not found in any allowlist')
|
||||
return False
|
||||
|
||||
|
||||
async def authenticate_github_user(auth_token) -> bool:
|
||||
user_verifier = UserVerifier()
|
||||
logger.info('Initialized user verifier')
|
||||
|
||||
if not user_verifier.is_active():
|
||||
logger.debug('No user verification sources configured - allowing all users')
|
||||
logger.info('No user verification sources configured - allowing all users')
|
||||
return True
|
||||
|
||||
logger.debug('Checking GitHub token')
|
||||
logger.info('Checking GitHub token')
|
||||
|
||||
if not auth_token:
|
||||
logger.warning('No GitHub token provided')
|
||||
return False
|
||||
|
||||
start_time = time.time()
|
||||
logger.info('Getting GitHub user from token')
|
||||
login = await get_github_user(auth_token)
|
||||
logger.info(f'Took {time.time() - start_time:.2f} seconds to get GitHub user')
|
||||
|
||||
if not user_verifier.is_user_allowed(login):
|
||||
logger.warning(f'GitHub user {login} not in allow list')
|
||||
@@ -114,7 +124,7 @@ async def get_github_user(token: str) -> str:
|
||||
Returns:
|
||||
github handle of the user
|
||||
"""
|
||||
logger.debug('Fetching GitHub user info from token')
|
||||
logger.info('Fetching GitHub user info from token')
|
||||
try:
|
||||
g = Github(token)
|
||||
user = await call_sync_from_async(g.get_user)
|
||||
|
||||
@@ -5,7 +5,6 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
@@ -74,14 +73,7 @@ file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
session_manager = SessionManager(config, file_store)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global session_manager
|
||||
async with session_manager:
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
LocalhostCORSMiddleware,
|
||||
allow_credentials=True,
|
||||
@@ -191,6 +183,8 @@ async def attach_session(request: Request, call_next):
|
||||
Returns:
|
||||
Response: The response from the next middleware or route handler.
|
||||
"""
|
||||
logger.info(f'Attaching session for {request.url.path}')
|
||||
start_time = time.time()
|
||||
non_authed_paths = [
|
||||
'/api/options/',
|
||||
'/api/github/callback',
|
||||
@@ -246,6 +240,8 @@ async def attach_session(request: Request, call_next):
|
||||
request.state.conversation = await session_manager.attach_to_conversation(
|
||||
request.state.sid
|
||||
)
|
||||
end_time = time.time()
|
||||
logger.info(f'Attach session time: {end_time - start_time:.2f}s')
|
||||
if request.state.conversation is None:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -276,7 +272,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
```
|
||||
- Send a message:
|
||||
```json
|
||||
{"action": "message", "args": {"content": "Hello, how are you?", "images_urls": ["base64_url1", "base64_url2"]}}
|
||||
{"action": "message", "args": {"content": "Hello, how are you?", "image_urls": ["base64_url1", "base64_url2"]}}
|
||||
```
|
||||
- Write contents to a file:
|
||||
```json
|
||||
@@ -494,9 +490,12 @@ async def list_files(request: Request, path: str | None = None):
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Runtime not yet initialized'},
|
||||
)
|
||||
start_time = time.time()
|
||||
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
file_list = await call_sync_from_async(runtime.list_files, path)
|
||||
end_time = time.time()
|
||||
logger.info(f'List files time: {end_time - start_time:.2f}s')
|
||||
if path:
|
||||
file_list = [os.path.join(path, f) for f in file_list]
|
||||
|
||||
@@ -517,6 +516,8 @@ async def list_files(request: Request, path: str | None = None):
|
||||
return file_list
|
||||
|
||||
file_list = await filter_for_gitignore(file_list, '')
|
||||
end_time = time.time()
|
||||
logger.info(f'Filter files time: {end_time - start_time:.2f}s')
|
||||
|
||||
return file_list
|
||||
|
||||
@@ -870,12 +871,15 @@ def github_callback(auth_code: AuthCode):
|
||||
|
||||
@app.post('/api/authenticate')
|
||||
async def authenticate(request: Request):
|
||||
logger.info('Authenticating user via GitHub waitlist')
|
||||
token = request.headers.get('X-GitHub-Token')
|
||||
if not await authenticate_github_user(token):
|
||||
logger.warning('User not authorized via GitHub waitlist')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Not authorized via GitHub waitlist'},
|
||||
)
|
||||
logger.info('User authenticated via GitHub waitlist')
|
||||
|
||||
# Create a signed JWT token with 1-hour expiration
|
||||
cookie_data = {
|
||||
@@ -900,6 +904,34 @@ async def authenticate(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
@app.get('/api/vscode-url')
|
||||
async def get_vscode_url(request: Request):
|
||||
"""Get the VSCode URL.
|
||||
|
||||
This endpoint allows getting the VSCode URL.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastAPI request object.
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response indicating the success of the operation.
|
||||
"""
|
||||
try:
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.debug(f'Runtime type: {type(runtime)}')
|
||||
logger.debug(f'Runtime VSCode URL: {runtime.vscode_url}')
|
||||
return JSONResponse(status_code=200, content={'vscode_url': runtime.vscode_url})
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting VSCode URL: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
'vscode_url': None,
|
||||
'error': f'Error getting VSCode URL: {e}',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import FastAPI, WebSocket
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ class AgentSession:
|
||||
sid=self.sid,
|
||||
plugins=agent.sandbox_plugins,
|
||||
status_callback=self._status_callback,
|
||||
headless_mode=False,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -36,6 +36,7 @@ class Conversation:
|
||||
event_stream=self.event_stream,
|
||||
sid=self.sid,
|
||||
attach_to_existing=True,
|
||||
headless_mode=False,
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.stream import session_exists
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.server.session.conversation import Conversation
|
||||
from openhands.server.session.session import Session
|
||||
from openhands.storage.files import FileStore
|
||||
@@ -18,78 +15,23 @@ from openhands.storage.files import FileStore
|
||||
class SessionManager:
|
||||
config: AppConfig
|
||||
file_store: FileStore
|
||||
cleanup_interval: int = 300
|
||||
session_timeout: int = 600
|
||||
_sessions: dict[str, Session] = field(default_factory=dict)
|
||||
_session_cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
if not self._session_cleanup_task:
|
||||
self._session_cleanup_task = asyncio.create_task(self._cleanup_sessions())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
if self._session_cleanup_task:
|
||||
self._session_cleanup_task.cancel()
|
||||
self._session_cleanup_task = None
|
||||
|
||||
def add_or_restart_session(self, sid: str, ws_conn: WebSocket) -> Session:
|
||||
if sid in self._sessions:
|
||||
self._sessions[sid].close()
|
||||
self._sessions[sid] = Session(
|
||||
return Session(
|
||||
sid=sid, file_store=self.file_store, ws=ws_conn, config=self.config
|
||||
)
|
||||
return self._sessions[sid]
|
||||
|
||||
def get_session(self, sid: str) -> Session | None:
|
||||
if sid not in self._sessions:
|
||||
return None
|
||||
return self._sessions.get(sid)
|
||||
|
||||
async def attach_to_conversation(self, sid: str) -> Conversation | None:
|
||||
start_time = time.time()
|
||||
if not await session_exists(sid, self.file_store):
|
||||
return None
|
||||
c = Conversation(sid, file_store=self.file_store, config=self.config)
|
||||
await c.connect()
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
|
||||
)
|
||||
return c
|
||||
|
||||
async def detach_from_conversation(self, conversation: Conversation):
|
||||
await conversation.disconnect()
|
||||
|
||||
async def send(self, sid: str, data: dict[str, object]) -> bool:
|
||||
"""Sends data to the client."""
|
||||
session = self.get_session(sid)
|
||||
if session is None:
|
||||
logger.error(f'*** No session found for {sid}, skipping message ***')
|
||||
return False
|
||||
return await session.send(data)
|
||||
|
||||
async def send_error(self, sid: str, message: str) -> bool:
|
||||
"""Sends an error message to the client."""
|
||||
return await self.send(sid, {'error': True, 'message': message})
|
||||
|
||||
async def send_message(self, sid: str, message: str) -> bool:
|
||||
"""Sends a message to the client."""
|
||||
return await self.send(sid, {'message': message})
|
||||
|
||||
async def _cleanup_sessions(self):
|
||||
while should_continue():
|
||||
current_time = time.time()
|
||||
session_ids_to_remove = []
|
||||
for sid, session in list(self._sessions.items()):
|
||||
# if session inactive for a long time, remove it
|
||||
if (
|
||||
not session.is_alive
|
||||
and current_time - session.last_active_ts > self.session_timeout
|
||||
):
|
||||
session_ids_to_remove.append(sid)
|
||||
|
||||
for sid in session_ids_to_remove:
|
||||
to_del_session: Session | None = self._sessions.pop(sid, None)
|
||||
if to_del_session is not None:
|
||||
to_del_session.close()
|
||||
logger.debug(
|
||||
f'Session {sid} and related resource have been removed due to inactivity.'
|
||||
)
|
||||
|
||||
await asyncio.sleep(self.cleanup_interval)
|
||||
|
||||
@@ -21,9 +21,9 @@ from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.server.session.agent_session import AgentSession
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
class Session:
|
||||
@@ -163,7 +163,7 @@ class Session:
|
||||
return
|
||||
event = event_from_dict(data.copy())
|
||||
# This checks if the model supports images
|
||||
if isinstance(event, MessageAction) and event.images_urls:
|
||||
if isinstance(event, MessageAction) and event.image_urls:
|
||||
controller = self.agent_session.controller
|
||||
if controller:
|
||||
if controller.agent.llm.config.disable_vision:
|
||||
|
||||
@@ -19,13 +19,16 @@ class PromptManager:
|
||||
Attributes:
|
||||
prompt_dir (str): Directory containing prompt templates.
|
||||
agent_skills_docs (str): Documentation of agent skills.
|
||||
microagent_dir (str): Directory containing microagent specifications.
|
||||
disabled_microagents (list[str] | None): List of microagents to disable. If None, all microagents are enabled.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prompt_dir: str,
|
||||
microagent_dir: str = '',
|
||||
microagent_dir: str | None = None,
|
||||
agent_skills_docs: str = '',
|
||||
disabled_microagents: list[str] | None = None,
|
||||
):
|
||||
self.prompt_dir: str = prompt_dir
|
||||
self.agent_skills_docs: str = agent_skills_docs
|
||||
@@ -43,9 +46,15 @@ class PromptManager:
|
||||
]
|
||||
for microagent_file in microagent_files:
|
||||
microagent = MicroAgent(microagent_file)
|
||||
self.microagents[microagent.name] = microagent
|
||||
if (
|
||||
disabled_microagents is None
|
||||
or microagent.name not in disabled_microagents
|
||||
):
|
||||
self.microagents[microagent.name] = microagent
|
||||
|
||||
def _load_template(self, template_name: str) -> Template:
|
||||
if self.prompt_dir is None:
|
||||
raise ValueError('Prompt directory is not set')
|
||||
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
|
||||
if not os.path.exists(template_path):
|
||||
raise FileNotFoundError(f'Prompt file {template_path} not found')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from tenacity import RetryCallState
|
||||
from tenacity.stop import stop_base
|
||||
|
||||
from openhands.runtime.utils.shutdown_listener import should_exit
|
||||
from openhands.utils.shutdown_listener import should_exit
|
||||
|
||||
|
||||
class stop_if_should_exit(stop_base):
|
||||
|
||||
41
poetry.lock
generated
41
poetry.lock
generated
@@ -1562,6 +1562,17 @@ files = [
|
||||
{file = "dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diskcache"
|
||||
version = "5.6.3"
|
||||
description = "Disk Cache -- Disk and file backed persistent cache."
|
||||
optional = false
|
||||
python-versions = ">=3"
|
||||
files = [
|
||||
{file = "diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19"},
|
||||
{file = "diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.9"
|
||||
@@ -3934,13 +3945,13 @@ types-tqdm = "*"
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.52.3"
|
||||
version = "1.52.5"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
|
||||
files = [
|
||||
{file = "litellm-1.52.3-py3-none-any.whl", hash = "sha256:fc8d5d53ba184cd570ae50d9acefa53c521225b62244adedea129794e98828b6"},
|
||||
{file = "litellm-1.52.3.tar.gz", hash = "sha256:4718235cbd6dea8db99b08e884a07f7ac7fad4a4b12597e20d8ff622295e1e05"},
|
||||
{file = "litellm-1.52.5-py3-none-any.whl", hash = "sha256:38c0f30a849b80c99cfc56f96c4c7563d5ced83f08fd7fc2129011ddc4414ac5"},
|
||||
{file = "litellm-1.52.5.tar.gz", hash = "sha256:9708c02983c7ed22fc18c96e167bf1c4ed9672de397d413e7957c216dfc911e6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5629,6 +5640,28 @@ files = [
|
||||
[package.dependencies]
|
||||
numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.1.0"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
files = [
|
||||
{file = "openhands_aci-0.1.0-py3-none-any.whl", hash = "sha256:f28e5a32e394d1e643f79bf8af27fe44d039cb71729d590f9f3ee0c23c075f00"},
|
||||
{file = "openhands_aci-0.1.0.tar.gz", hash = "sha256:babc55f516efbb27eb7e528662e14b75c902965c48a110408fda824b83ea4461"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
diskcache = ">=5.6.3,<6.0.0"
|
||||
gitpython = "*"
|
||||
grep-ast = "0.3.3"
|
||||
litellm = "*"
|
||||
networkx = "*"
|
||||
numpy = "*"
|
||||
pandas = "*"
|
||||
scipy = "*"
|
||||
tree-sitter = "0.21.3"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.25.0"
|
||||
@@ -10178,4 +10211,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "245fd4cd56a3c95b2dd4f3a06251f7de82ad0300de7349f0710aac1f92a151b7"
|
||||
content-hash = "a552f630dfdb9221eda6932e71e67a935c52ebfe4388ec9ef4b3245e7df2f82b"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
@@ -63,6 +63,7 @@ opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = "^0.64.145"
|
||||
runloop-api-client = "0.7.0"
|
||||
pygithub = "^2.5.0"
|
||||
openhands-aci = "^0.1.0"
|
||||
|
||||
[tool.poetry.group.llama-index.dependencies]
|
||||
llama-index = "*"
|
||||
@@ -94,7 +95,6 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@@ -125,7 +125,6 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
@@ -224,6 +224,7 @@ def _load_runtime(
|
||||
config = load_app_config()
|
||||
config.run_as_openhands = run_as_openhands
|
||||
config.sandbox.force_rebuild_runtime = force_rebuild_runtime
|
||||
config.sandbox.keep_runtime_alive = False
|
||||
# Folder where all tests create their own folder
|
||||
global test_mount_path
|
||||
if use_workspace:
|
||||
|
||||
@@ -64,7 +64,7 @@ def get_config(
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -137,7 +137,7 @@ def process_instance(
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
runtime = create_runtime(config)
|
||||
runtime = create_runtime(config, headless_mode=False)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
try:
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_event_props_serialization_deserialization():
|
||||
'action': 'message',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'images_urls': None,
|
||||
'image_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
@@ -77,7 +77,7 @@ def test_message_action_serialization_deserialization():
|
||||
'action': 'message',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'images_urls': None,
|
||||
'image_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import sys
|
||||
import docx
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.plugins.agent_skills.agentskills import file_editor
|
||||
from openhands.runtime.plugins.agent_skills.file_ops.file_ops import (
|
||||
WINDOW,
|
||||
_print_window,
|
||||
@@ -781,7 +780,7 @@ def test_file_editor_create(tmp_path):
|
||||
assert result is not None
|
||||
assert (
|
||||
result
|
||||
== f'ERROR:\nThe path {random_file} does not exist. Please provide a valid path.'
|
||||
== f'ERROR:\nInvalid `path` parameter: {random_file}. The path {random_file} does not exist. Please provide a valid path.'
|
||||
)
|
||||
|
||||
# create a file
|
||||
@@ -800,218 +799,3 @@ def test_file_editor_create(tmp_path):
|
||||
1\tLine 6
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_file(tmp_path):
|
||||
random_dir = tmp_path / 'dir_1'
|
||||
random_dir.mkdir()
|
||||
random_file = random_dir / 'a.txt'
|
||||
return random_file
|
||||
|
||||
|
||||
def test_file_editor_create_and_view(setup_file):
|
||||
random_file = setup_file
|
||||
|
||||
# Test create command
|
||||
result = file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
print(result)
|
||||
assert result == f'File created successfully at: {random_file}'
|
||||
|
||||
# Test view command for file
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
print(result)
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
# Test view command for directory
|
||||
result = file_editor(command='view', path=str(random_file.parent))
|
||||
assert f'{random_file.parent}' in result
|
||||
assert f'{random_file.name}' in result
|
||||
|
||||
|
||||
def test_file_editor_view_nonexistent(setup_file):
|
||||
random_file = setup_file
|
||||
|
||||
# Test view command for non-existent file
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
assert (
|
||||
result
|
||||
== f'ERROR:\nThe path {random_file} does not exist. Please provide a valid path.'
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_str_replace(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test str_replace command
|
||||
result = file_editor(
|
||||
command='str_replace',
|
||||
path=str(random_file),
|
||||
old_str='Line 2',
|
||||
new_str='New Line 2',
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""The file {random_file} has been edited. Here's the result of running `cat -n` on a snippet of {random_file}:
|
||||
1\tLine 1
|
||||
2\tNew Line 2
|
||||
3\tLine 3
|
||||
Review the changes and make sure they are as expected. Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
# View the file after str_replace
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
print(result)
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tNew Line 2
|
||||
3\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_str_replace_non_existent(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test str_replace with non-existent string
|
||||
result = file_editor(
|
||||
command='str_replace',
|
||||
path=str(random_file),
|
||||
old_str='Non-existent Line',
|
||||
new_str='New Line',
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f'ERROR:\nNo replacement was performed, old_str `Non-existent Line` did not appear verbatim in {random_file}.'
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_insert(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test insert command
|
||||
result = file_editor(
|
||||
command='insert', path=str(random_file), insert_line=2, new_str='Inserted Line'
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""The file {random_file} has been edited. Here's the result of running `cat -n` on a snippet of the edited file:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tInserted Line
|
||||
4\tLine 3
|
||||
Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
# View the file after insert
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tInserted Line
|
||||
4\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_insert_invalid_line(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test insert with invalid line number
|
||||
result = file_editor(
|
||||
command='insert',
|
||||
path=str(random_file),
|
||||
insert_line=10,
|
||||
new_str='Invalid Insert',
|
||||
)
|
||||
assert (
|
||||
result
|
||||
== 'ERROR:\nInvalid `insert_line` parameter: 10. It should be within the range of lines of the file: [0, 3]'
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_undo_edit(setup_file):
|
||||
random_file = setup_file
|
||||
result = file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
print(result)
|
||||
assert result == f"""File created successfully at: {random_file}"""
|
||||
|
||||
# Make an edit
|
||||
result = file_editor(
|
||||
command='str_replace',
|
||||
path=str(random_file),
|
||||
old_str='Line 2',
|
||||
new_str='New Line 2',
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""The file {random_file} has been edited. Here's the result of running `cat -n` on a snippet of {random_file}:
|
||||
1\tLine 1
|
||||
2\tNew Line 2
|
||||
3\tLine 3
|
||||
Review the changes and make sure they are as expected. Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
# Test undo_edit command
|
||||
result = file_editor(command='undo_edit', path=str(random_file))
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""Last edit to {random_file} undone successfully. Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tLine 3
|
||||
"""
|
||||
)
|
||||
|
||||
# View the file after undo_edit
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_undo_edit_no_edits(tmp_path):
|
||||
random_file = tmp_path / 'a.txt'
|
||||
random_file.touch()
|
||||
|
||||
# Test undo_edit when no edits have been made
|
||||
result = file_editor(command='undo_edit', path=str(random_file))
|
||||
print(result)
|
||||
assert result == f'ERROR:\nNo edit history found for {random_file}.'
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_event_serialization_deserialization():
|
||||
'message': 'This is a test.',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'images_urls': None,
|
||||
'image_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
@@ -38,7 +38,7 @@ def test_array_serialization_deserialization():
|
||||
'message': 'This is a test.',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'images_urls': None,
|
||||
'image_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -119,3 +119,63 @@ def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
|
||||
# Clean up temporary files
|
||||
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))
|
||||
os.remove(os.path.join(prompt_dir, 'user_prompt.j2'))
|
||||
|
||||
|
||||
def test_prompt_manager_disabled_microagents(prompt_dir, agent_skills_docs):
|
||||
# Create test microagent files
|
||||
microagent1_name = 'test_microagent1'
|
||||
microagent2_name = 'test_microagent2'
|
||||
microagent1_content = """
|
||||
---
|
||||
name: Test Microagent 1
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- test1
|
||||
---
|
||||
|
||||
Test microagent 1 content
|
||||
"""
|
||||
microagent2_content = """
|
||||
---
|
||||
name: Test Microagent 2
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- test2
|
||||
---
|
||||
|
||||
Test microagent 2 content
|
||||
"""
|
||||
|
||||
# Create temporary micro agent files
|
||||
os.makedirs(os.path.join(prompt_dir, 'micro'), exist_ok=True)
|
||||
with open(os.path.join(prompt_dir, 'micro', f'{microagent1_name}.md'), 'w') as f:
|
||||
f.write(microagent1_content)
|
||||
with open(os.path.join(prompt_dir, 'micro', f'{microagent2_name}.md'), 'w') as f:
|
||||
f.write(microagent2_content)
|
||||
|
||||
# Test that specific microagents can be disabled
|
||||
manager = PromptManager(
|
||||
prompt_dir=prompt_dir,
|
||||
microagent_dir=os.path.join(prompt_dir, 'micro'),
|
||||
agent_skills_docs=agent_skills_docs,
|
||||
disabled_microagents=['Test Microagent 1'],
|
||||
)
|
||||
|
||||
assert len(manager.microagents) == 1
|
||||
assert 'Test Microagent 2' in manager.microagents
|
||||
assert 'Test Microagent 1' not in manager.microagents
|
||||
|
||||
# Test that all microagents are enabled by default
|
||||
manager = PromptManager(
|
||||
prompt_dir=prompt_dir,
|
||||
microagent_dir=os.path.join(prompt_dir, 'micro'),
|
||||
agent_skills_docs=agent_skills_docs,
|
||||
)
|
||||
|
||||
assert len(manager.microagents) == 2
|
||||
assert 'Test Microagent 1' in manager.microagents
|
||||
assert 'Test Microagent 2' in manager.microagents
|
||||
|
||||
# Clean up temporary files
|
||||
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent1_name}.md'))
|
||||
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent2_name}.md'))
|
||||
|
||||
@@ -135,7 +135,7 @@ def test_generate_dockerfile_build_from_scratch():
|
||||
)
|
||||
assert base_image in dockerfile_content
|
||||
assert 'apt-get update' in dockerfile_content
|
||||
assert 'apt-get install -y wget curl sudo apt-utils' in dockerfile_content
|
||||
assert 'wget curl sudo apt-utils git' in dockerfile_content
|
||||
assert 'poetry' in dockerfile_content and '-c conda-forge' in dockerfile_content
|
||||
assert 'python=3.12' in dockerfile_content
|
||||
|
||||
@@ -155,7 +155,7 @@ def test_generate_dockerfile_build_from_lock():
|
||||
)
|
||||
|
||||
# These commands SHOULD NOT include in the dockerfile if build_from_scratch is False
|
||||
assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content
|
||||
assert 'wget curl sudo apt-utils git' not in dockerfile_content
|
||||
assert '-c conda-forge' not in dockerfile_content
|
||||
assert 'python=3.12' not in dockerfile_content
|
||||
assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content
|
||||
@@ -173,7 +173,7 @@ def test_generate_dockerfile_build_from_versioned():
|
||||
)
|
||||
|
||||
# these commands should not exist when build from versioned
|
||||
assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content
|
||||
assert 'wget curl sudo apt-utils git' not in dockerfile_content
|
||||
assert '-c conda-forge' not in dockerfile_content
|
||||
assert 'python=3.12' not in dockerfile_content
|
||||
assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content
|
||||
|
||||
Reference in New Issue
Block a user