Compare commits

...

53 Commits

Author SHA1 Message Date
Robert Brennan
dbda7bcc82 add cloud sql 2024-11-15 13:55:50 -05:00
Robert Brennan
2f275dce9e new pg impl 2024-11-15 13:55:04 -05:00
Robert Brennan
340b9ead40 debug logs 2024-11-15 13:46:01 -05:00
Robert Brennan
1fba72043c create db if not exists 2024-11-15 13:38:06 -05:00
Robert Brennan
bcab72c981 add logging 2024-11-15 13:26:36 -05:00
Robert Brennan
5949f5bdb6 fix import 2024-11-15 13:19:32 -05:00
Robert Brennan
20d4a0cce2 add asyncpg 2024-11-15 13:18:28 -05:00
Robert Brennan
37ee90cf6f add gcp support 2024-11-15 13:17:46 -05:00
Robert Brennan
69c646ebf9 add pg socket 2024-11-15 12:56:25 -05:00
Robert Brennan
71e57dc069 better pg creds 2024-11-15 12:52:32 -05:00
Robert Brennan
3f03fb69c8 add pg socket 2024-11-15 12:49:38 -05:00
Tim O'Farrell
551a5a72e1 Client side error fix 2024-11-15 09:55:39 -07:00
Tim O'Farrell
2aa6bab85f Fix initial startup 2024-11-15 09:27:44 -07:00
Tim O'Farrell
3f0e619997 WIP 2024-11-15 08:52:12 -07:00
Tim O'Farrell
511ce8cc3a Fixed closing 2024-11-15 08:29:51 -07:00
Tim O'Farrell
a1b53a9498 Culled dead code 2024-11-15 07:39:30 -07:00
Tim O'Farrell
767878563e WIP 2024-11-15 07:15:21 -07:00
Tim O'Farrell
b2bd23ac86 Fix for test errors - client side seems to work 2024-11-14 18:46:13 -07:00
Tim O'Farrell
f0487e6818 Server side code is working - now on the client side 2024-11-14 18:37:33 -07:00
Tim O'Farrell
b85fbf39fd Tokens in init event 2024-11-14 15:45:57 -07:00
Tim O'Farrell
8b4d263319 Merge branch 'main' into feat-socket-io 2024-11-14 07:25:01 -07:00
Tim O'Farrell
ff7783ec81 Removed retry - socketio does this anyway 2024-11-14 07:24:37 -07:00
Rohit Malhotra
38dc41ca42 Fix: [Bug] Do not render editor action buttons (save/discard) when displaying non-code files (#4903) 2024-11-14 09:09:28 +02:00
Engel Nyst
8dee334236 Context Window Exceeded fix (#4977) 2024-11-14 02:42:39 +00:00
Tim O'Farrell
d4b20c284d Now using socket io 2024-11-13 16:38:03 -07:00
Tim O'Farrell
2501cce470 Merge branch 'main' into feat-socket-io 2024-11-13 16:20:49 -07:00
Tim O'Farrell
ca9aefd7b2 WIP - frontend is still janky 2024-11-13 16:18:39 -07:00
Tim O'Farrell
92586a090d Server side init is working 2024-11-13 14:41:57 -07:00
Tim O'Farrell
18e774dd8a Fix for merge error 2024-11-13 13:37:07 -07:00
Engel Nyst
a93f1402de Clean up file logs (#4979) 2024-11-13 20:17:21 +00:00
Tim O'Farrell
3a59e037fb Merge branch 'main' into feat-socket-io 2024-11-13 12:56:45 -07:00
Tim O'Farrell
5973c0c269 Working through socket issues 2024-11-13 12:55:51 -07:00
Tim O'Farrell
2fa8c4e14d Initial stab at reimplementing session management 2024-11-13 12:31:02 -07:00
Tim O'Farrell
c2a5fbceb7 Server side work for socketio.
Still needs testing and integration of proper client (Rather than the mock I've been using)
2024-11-13 11:45:43 -07:00
Robert Brennan
bc3f0ac24a fix imports (#4974) 2024-11-13 17:04:16 +00:00
Robert Brennan
f55ddbed0e fix docker leak (#4970) 2024-11-14 00:23:07 +08:00
Xingyao Wang
fd81670ba8 feat: add VSCode to OpenHands runtime and UI (#4745)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Robert Brennan <accounts@rbren.io>
2024-11-14 00:20:49 +08:00
sp.wack
79ed4e3567 fix(frontend): Recover full message history if exists (#4961) 2024-11-13 15:38:30 +02:00
sp.wack
b3fbbbaa9d feat(frontend): Move posthog key to config and upgrade posthog-js (#4940) 2024-11-13 07:56:04 +00:00
tofarr
87c02177d7 Reconnecting websockets (#4954) 2024-11-13 09:38:26 +02:00
OpenHands
207df9dd30 Fix issue #4912: [Bug]: BedrockException: "The number of toolResult blocks at messages.2.content exceeds the number of toolUse blocks of previous turn.". (#4937)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2024-11-12 17:23:11 -05:00
tofarr
59f7093428 Fix max iterations (#4949) 2024-11-12 21:09:43 +00:00
sp.wack
123fb4b75d feat(posthog): Add saas login event (#4948) 2024-11-12 20:37:59 +00:00
mamoodi
40e2d28e87 Release 0.13.1 (#4947) 2024-11-12 15:08:10 -05:00
OpenHands
c555611d58 Fix issue #4941: [Bug]: Browser tab does not reset after starting a new session (#4945)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-12 19:40:12 +00:00
Calvin Smith
50e7da9c3d fix(evaluation): SWE-bench evaluation script supports multiprocessing (#4943) 2024-11-12 12:19:57 -07:00
sp.wack
0cfb132ab7 fix(frontend): Remove dotted outline on focus (#4926) 2024-11-12 18:27:06 +02:00
Robert Brennan
17f4c6e1a9 Refactor sessions a bit, and fix issue where runtimes get killed (#4900) 2024-11-12 16:20:36 +00:00
Xingyao Wang
910b283ac2 fix(llm): bedrock throw errors if content contains empty string (#4935) 2024-11-12 15:53:22 +00:00
OpenHands
b54724ac3f Fix issue #4931: Make use of microagents configurable in codeact_agent (#4932)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-12 15:42:13 +00:00
Robert Brennan
0633a99298 Fix resume runtime after a pause (#4904) 2024-11-12 09:03:02 -05:00
Ryan H. Tran
d9c5f11046 Replace file editor with openhands-aci (#4782) 2024-11-12 21:26:33 +08:00
Engel Nyst
32fdcd58e5 Update litellm (#4927) 2024-11-12 11:24:19 +00:00
79 changed files with 2486 additions and 1588 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
@@ -40,6 +40,7 @@
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",
@@ -5576,6 +5577,11 @@
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"dev": true
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -8469,6 +8475,46 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
"integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -19749,9 +19795,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",
@@ -22587,6 +22633,32 @@
"tslib": "^2.0.3"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -25317,6 +25389,14 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -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",
@@ -39,6 +39,7 @@
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",

View File

@@ -1,4 +1,5 @@
{
"APP_MODE": "oss",
"GITHUB_CLIENT_ID": ""
}
"GITHUB_CLIENT_ID": "",
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
}

View File

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

View File

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

View 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

View File

@@ -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(() => {

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import posthog from "posthog-js";
import React from "react";
import { io, Socket } from "socket.io-client";
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";
export enum WsClientProviderStatus {
STOPPED,
@@ -40,115 +41,146 @@ export function WsClientProvider({
settings,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const wsRef = React.useRef<WebSocket | null>(null);
const sioRef = React.useRef<Socket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const disconnectRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
function send(event: Record<string, unknown>) {
if (!wsRef.current) {
if (!sioRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
wsRef.current.send(JSON.stringify(event));
sioRef.current.emit("oh_action", event);
}
function handleOpen() {
function handleConnect() {
setStatus(WsClientProviderStatus.OPENING);
const initEvent = {
const initEvent: Record<string, unknown> = {
action: ActionType.INIT,
args: settings,
};
if (token) {
initEvent.token = token;
}
if (ghToken) {
initEvent.github_token = ghToken;
}
if (events.length) {
// Wrong. Events is out of sync here...
initEvent.latest_event_id = `${events[events.length - 1].id}`;
}
send(initEvent);
}
function handleMessage(messageEvent: MessageEvent) {
const event = JSON.parse(messageEvent.data);
function handleMessage(event: Record<string, unknown>) {
setEvents((prevEvents) => [...prevEvents, event]);
if (event.extras?.agent_state === AgentState.INIT) {
setStatus(WsClientProviderStatus.ACTIVE);
}
const extras = event.extras as Record<string, unknown>;
if (
status !== WsClientProviderStatus.ACTIVE &&
event?.observation === "error"
) {
setStatus(WsClientProviderStatus.ERROR);
}
if (event.token) {
setStatus(WsClientProviderStatus.ACTIVE);
} else {
handleAssistantMessage(event);
}
}
function handleClose() {
function handleDisconnect() {
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
wsRef.current = null;
// setEvents([]);
// sioRef.current = null;
}
function handleError(event: Event) {
function handleError() {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
setStatus(WsClientProviderStatus.ERROR);
sioRef.current?.disconnect();
}
// Connect websocket
React.useEffect(() => {
let ws = wsRef.current;
let sio = sioRef.current;
// If disabled close any existing websockets...
// If disabled disconnect any existing websockets...
if (!enabled) {
if (ws) {
ws.close();
if (sio) {
sio.disconnect();
}
wsRef.current = null;
return () => {};
}
// If there is no websocket or the tokens have changed or the current websocket is closed,
// If there is no websocket or the tokens have changed or the current websocket is disconnected,
// create a new one
if (
!ws ||
!sio ||
(tokenRef.current && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current ||
ws.readyState === WebSocket.CLOSED ||
ws.readyState === WebSocket.CLOSING
ghToken !== ghTokenRef.current
) {
ws?.close();
sio?.disconnect();
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
token || "NO_JWT",
ghToken || "NO_GITHUB",
]);
sio = io(baseUrl, {
transports: ["websocket"],
// extraHeaders: {
// Testy: "TESTER"
// },
// We force a new connection, because the headers may have changed.
// forceNew: true,
// Had to do this for now because reconnection actually starts a new session,
// which we don't want - The reconnect has the same headers as the original
// which don't include the original session id
// reconnection: false,
// reconnectionDelay: 1000,
// reconnectionDelayMax : 5000,
// reconnectionAttempts: 5
});
}
ws.addEventListener("open", handleOpen);
ws.addEventListener("message", handleMessage);
ws.addEventListener("error", handleError);
ws.addEventListener("close", handleClose);
wsRef.current = ws;
sio.on("connect", handleConnect);
sio.on("oh_event", handleMessage);
sio.on("connect_error", handleError);
sio.on("connect_failed", handleError);
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
tokenRef.current = token;
ghTokenRef.current = ghToken;
return () => {
ws.removeEventListener("open", handleOpen);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.removeEventListener("close", handleClose);
sio.off("connect", handleConnect);
sio.off("oh_event", handleMessage);
sio.off("connect_error", handleError);
sio.off("connect_failed", handleError);
sio.off("disconnect", handleDisconnect);
};
}, [enabled, token, ghToken]);
}, [enabled, token, ghToken, events]);
// 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.
// before actually disconnecting the socket and cancel the operation if the component gets remounted.
React.useEffect(() => {
const timeout = closeRef.current;
const timeout = disconnectRef.current;
if (timeout != null) {
clearTimeout(timeout);
}
return () => {
closeRef.current = setTimeout(() => {
wsRef.current?.close();
disconnectRef.current = setTimeout(() => {
const sio = sioRef.current;
if (sio) {
sio.off("disconnect", handleDisconnect);
sio.disconnect();
}
}, 100);
};
}, []);

View File

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

View File

@@ -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?",

View File

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

View File

@@ -12,6 +12,18 @@ import CodeEditorCompoonent from "./code-editor-component";
import { useFiles } from "#/context/files";
import { EditorActions } from "#/components/editor-actions";
const ASSET_FILE_TYPES = [
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".pdf",
".mp4",
".webm",
".ogg",
];
export const clientLoader = async () => {
const token = localStorage.getItem("token");
return json({ token });
@@ -104,6 +116,10 @@ function CodeEditor() {
if (selectedPath) discardChanges(selectedPath);
};
const isAssetFileType = selectedPath
? ASSET_FILE_TYPES.some((ext) => selectedPath.endsWith(ext))
: false;
return (
<div className="flex h-full bg-neutral-900 relative">
<FileExplorer
@@ -112,7 +128,7 @@ function CodeEditor() {
error={errors.getFiles}
/>
<div className="w-full">
{selectedPath && (
{selectedPath && !isAssetFileType && (
<div className="flex w-full items-center justify-between self-end p-2">
<span className="text-sm text-neutral-500">{selectedPath}</span>
<EditorActions

View File

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

View File

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

View File

@@ -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",
},
});
},

View File

@@ -82,6 +82,13 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY,
},
"/socket.io": {
target: WS_URL,
ws: true,
changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY,
//rewriteWsOrigin: true,
}
},
},
ssr: {

View File

@@ -103,15 +103,17 @@ class CodeActAgent(Agent):
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
disabled_microagents=self.config.disabled_microagents,
)
else:
self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
agent_skills_docs=AgentSkillsRequirement.documentation,
disabled_microagents=self.config.disabled_microagents,
)
self.pending_actions: deque[Action] = deque()

View File

@@ -5,6 +5,7 @@ import traceback
from typing import Callable, ClassVar, Type
import litellm
from litellm.exceptions import ContextWindowExceededError
from openhands.controller.agent import Agent
from openhands.controller.state.state import State, TrafficControlState
@@ -42,7 +43,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 = (
@@ -485,6 +486,15 @@ class AgentController:
EventSource.AGENT,
)
return
except ContextWindowExceededError:
# When context window is exceeded, keep roughly half of agent interactions
self.state.history = self._apply_conversation_window(self.state.history)
# Save the ID of the first event in our truncated history for future reloading
if self.state.history:
self.state.start_id = self.state.history[0].id
# Don't add error event - let the agent retry with reduced context
return
if action.runnable:
if self.state.confirmation_mode and (
@@ -659,6 +669,12 @@ class AgentController:
- For delegate events (between AgentDelegateAction and AgentDelegateObservation):
- Excludes all events between the action and observation
- Includes the delegate action and observation themselves
The history is loaded in two parts if truncation_id is set:
1. First user message from start_id onwards
2. Rest of history from truncation_id to the end
Otherwise loads normally from start_id.
"""
# define range of events to fetch
@@ -680,8 +696,33 @@ class AgentController:
self.state.history = []
return
# Get all events, filtering out backend events and hidden events
events = list(
events: list[Event] = []
# If we have a truncation point, get first user message and then rest of history
if hasattr(self.state, 'truncation_id') and self.state.truncation_id > 0:
# Find first user message from stream
first_user_msg = next(
(
e
for e in self.event_stream.get_events(
start_id=start_id,
end_id=end_id,
reverse=False,
filter_out_type=self.filter_out,
filter_hidden=True,
)
if isinstance(e, MessageAction) and e.source == EventSource.USER
),
None,
)
if first_user_msg:
events.append(first_user_msg)
# the rest of the events are from the truncation point
start_id = self.state.truncation_id
# Get rest of history
events_to_add = list(
self.event_stream.get_events(
start_id=start_id,
end_id=end_id,
@@ -690,6 +731,7 @@ class AgentController:
filter_hidden=True,
)
)
events.extend(events_to_add)
# Find all delegate action/observation pairs
delegate_ranges: list[tuple[int, int]] = []
@@ -744,6 +786,92 @@ class AgentController:
# make sure history is in sync
self.state.start_id = start_id
def _apply_conversation_window(self, events: list[Event]) -> list[Event]:
"""Cuts history roughly in half when context window is exceeded, preserving action-observation pairs
and ensuring the first user message is always included.
The algorithm:
1. Cut history in half
2. Check first event in new history:
- If Observation: find and include its Action
- If MessageAction: ensure its related Action-Observation pair isn't split
3. Always include the first user message
Args:
events: List of events to filter
Returns:
Filtered list of events keeping newest half while preserving pairs
"""
if not events:
return events
# Find first user message - we'll need to ensure it's included
first_user_msg = next(
(
e
for e in events
if isinstance(e, MessageAction) and e.source == EventSource.USER
),
None,
)
# cut in half
mid_point = max(1, len(events) // 2)
kept_events = events[mid_point:]
# Handle first event in truncated history
if kept_events:
i = 0
while i < len(kept_events):
first_event = kept_events[i]
if isinstance(first_event, Observation) and first_event.cause:
# Find its action and include it
matching_action = next(
(
e
for e in reversed(events[:mid_point])
if isinstance(e, Action) and e.id == first_event.cause
),
None,
)
if matching_action:
kept_events = [matching_action] + kept_events
else:
self.log(
'warning',
f'Found Observation without matching Action at id={first_event.id}',
)
# drop this observation
kept_events = kept_events[1:]
break
elif isinstance(first_event, MessageAction) or (
isinstance(first_event, Action)
and first_event.source == EventSource.USER
):
# if it's a message action or a user action, keep it and continue to find the next event
i += 1
continue
else:
# if it's an action with source == EventSource.AGENT, we're good
break
# Save where to continue from in next reload
if kept_events:
self.state.truncation_id = kept_events[0].id
# Ensure first user message is included
if first_user_msg and first_user_msg not in kept_events:
kept_events = [first_user_msg] + kept_events
# start_id points to first user message
if first_user_msg:
self.state.start_id = first_user_msg.id
return kept_events
def _is_stuck(self):
"""Checks if the agent or its delegate is stuck in a loop.

View File

@@ -92,6 +92,8 @@ class State:
# start_id and end_id track the range of events in history
start_id: int = -1
end_id: int = -1
# truncation_id tracks where to load history after context window truncation
truncation_id: int = -1
almost_stuck: int = 0
delegates: dict[tuple[int, int], tuple[str, str]] = field(default_factory=dict)
# NOTE: This will never be used by the controller, but it can be used by different

View File

@@ -116,6 +116,7 @@ async def main():
event_stream=event_stream,
sid=sid,
plugins=agent_cls.sandbox_plugins,
headless_mode=True,
)
controller = AgentController(

View File

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

View File

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

View File

@@ -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',
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
]

View File

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

View File

@@ -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.')

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import atexit
import os
from pathlib import Path
import tempfile
import threading
from functools import lru_cache
from pathlib import Path
from typing import Callable
from zipfile import ZipFile
@@ -35,6 +36,7 @@ from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.base import Runtime
from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.impl.eventstream.containers import remove_all_containers
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.request import send_request
@@ -42,6 +44,15 @@ from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
atexit.register(remove_all_runtime_containers)
class LogBuffer:
"""Synchronous buffer for Docker container logs.
@@ -114,8 +125,6 @@ class EventStreamRuntime(Runtime):
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
container_name_prefix = 'openhands-runtime-'
# Need to provide this method to allow inheritors to init the Runtime
# without initting the EventStreamRuntime.
def init_base_runtime(
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}'
)

View File

@@ -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() }}

View File

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

View File

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

View File

@@ -1,17 +1,16 @@
import asyncio
import os
import re
import tempfile
import time
import uuid
import warnings
from contextlib import asynccontextmanager
import jwt
import requests
import socketio
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from openhands.core.schema.action import ActionType
from openhands.security.options import SecurityAnalyzers
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.github import (
@@ -20,8 +19,9 @@ from openhands.server.github import (
UserVerifier,
authenticate_github_user,
)
from openhands.server.pg_socket import AsyncPostgresAdapter
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.async_utils import call_async_from_sync, call_sync_from_async
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@@ -34,7 +34,6 @@ from fastapi import (
HTTPException,
Request,
UploadFile,
WebSocket,
status,
)
from fastapi.responses import FileResponse, JSONResponse
@@ -53,7 +52,6 @@ from openhands.events.action import (
NullAction,
)
from openhands.events.observation import (
AgentStateChangedObservation,
ErrorObservation,
FileReadObservation,
FileWriteObservation,
@@ -74,14 +72,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,
@@ -258,122 +249,6 @@ async def attach_session(request: Request, call_next):
return response
@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for receiving events from the client (i.e., the browser).
Once connected, the client can send various actions:
- Initialize the agent:
session management, and event streaming.
```json
{"action": "initialize", "args": {"LLM_MODEL": "ollama/llama3", "AGENT": "CodeActAgent", "LANGUAGE": "en", "LLM_API_KEY": "ollama"}}
Args:
```
websocket (WebSocket): The WebSocket connection object.
- Start a new development task:
```json
{"action": "start", "args": {"task": "write a bash script that prints hello"}}
```
- Send a message:
```json
{"action": "message", "args": {"content": "Hello, how are you?", "image_urls": ["base64_url1", "base64_url2"]}}
```
- Write contents to a file:
```json
{"action": "write", "args": {"path": "./greetings.txt", "content": "Hello, OpenHands?"}}
```
- Read the contents of a file:
```json
{"action": "read", "args": {"path": "./greetings.txt"}}
```
- Run a command:
```json
{"action": "run", "args": {"command": "ls -l", "thought": "", "confirmation_state": "confirmed"}}
```
- Run an IPython command:
```json
{"action": "run_ipython", "args": {"command": "print('Hello, IPython!')"}}
```
- Open a web page:
```json
{"action": "browse", "args": {"url": "https://arxiv.org/html/2402.01030v2"}}
```
- Add a task to the root_task:
```json
{"action": "add_task", "args": {"task": "Implement feature X"}}
```
- Update a task in the root_task:
```json
{"action": "modify_task", "args": {"id": "0", "state": "in_progress", "thought": ""}}
```
- Change the agent's state:
```json
{"action": "change_agent_state", "args": {"state": "paused"}}
```
- Finish the task:
```json
{"action": "finish", "args": {}}
```
"""
# Get protocols from Sec-WebSocket-Protocol header
protocols = websocket.headers.get('sec-websocket-protocol', '').split(', ')
# The first protocol should be our real protocol (e.g. 'openhands')
# The second protocol should contain our auth token
if len(protocols) < 3:
logger.error('Expected 3 websocket protocols, got %d', len(protocols))
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
real_protocol = protocols[0]
jwt_token = protocols[1] if protocols[1] != 'NO_JWT' else ''
github_token = protocols[2] if protocols[2] != 'NO_GITHUB' else ''
if not await authenticate_github_user(github_token):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await asyncio.wait_for(websocket.accept(subprotocol=real_protocol), 10)
if jwt_token:
sid = get_sid_from_token(jwt_token, config.jwt_secret)
if sid == '':
await websocket.send_json({'error': 'Invalid token', 'error_code': 401})
await websocket.close()
return
else:
sid = str(uuid.uuid4())
jwt_token = sign_token({'sid': sid}, config.jwt_secret)
logger.info(f'New session: {sid}')
session = session_manager.add_or_restart_session(sid, websocket)
await websocket.send_json({'token': jwt_token, 'status': 'ok'})
latest_event_id = -1
if websocket.query_params.get('latest_event_id'):
latest_event_id = int(websocket.query_params.get('latest_event_id'))
async_stream = AsyncEventStreamWrapper(
session.agent_session.event_stream, latest_event_id + 1
)
async for event in async_stream:
if isinstance(
event,
(
NullAction,
NullObservation,
ChangeAgentStateAction,
AgentStateChangedObservation,
),
):
continue
await websocket.send_json(event_to_dict(event))
await session.loop_recv()
@app.get('/api/options/models')
async def get_litellm_models() -> list[str]:
"""
@@ -900,6 +775,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:
@@ -910,3 +813,135 @@ class SPAStaticFiles(StaticFiles):
app.mount('/', SPAStaticFiles(directory='./frontend/build', html=True), name='dist')
use_manager = os.getenv('DB_HOST') or os.getenv('GCP_DB_INSTANCE')
manager = AsyncPostgresAdapter() if use_manager else None
if manager:
call_async_from_sync(manager.setup, 10)
sio = socketio.AsyncServer(
async_mode='asgi', cors_allowed_origins='*', client_manager=manager
)
app = socketio.ASGIApp(sio, other_asgi_app=app)
@sio.event
async def connect(connection_id: str, environ):
logger.info(f'sio:connect: {connection_id}')
@sio.event
async def oh_action(connection_id: str, data: dict):
"""WebSocket endpoint for receiving events from the client (i.e., the browser).
Once connected, the client can send various actions:
- Initialize the agent:
session management, and event streaming.
```json
{"action": "initialize", "args": {"LLM_MODEL": "ollama/llama3", "AGENT": "CodeActAgent", "LANGUAGE": "en", "LLM_API_KEY": "ollama"}}
Args:
```
websocket (WebSocket): The WebSocket connection object.
- Start a new development task:
```json
{"action": "start", "args": {"task": "write a bash script that prints hello"}}
```
- Send a message:
```json
{"action": "message", "args": {"content": "Hello, how are you?", "image_urls": ["base64_url1", "base64_url2"]}}
```
- Write contents to a file:
```json
{"action": "write", "args": {"path": "./greetings.txt", "content": "Hello, OpenHands?"}}
```
- Read the contents of a file:
```json
{"action": "read", "args": {"path": "./greetings.txt"}}
```
- Run a command:
```json
{"action": "run", "args": {"command": "ls -l", "thought": "", "confirmation_state": "confirmed"}}
```
- Run an IPython command:
```json
{"action": "run_ipython", "args": {"command": "print('Hello, IPython!')"}}
```
- Open a web page:
```json
{"action": "browse", "args": {"url": "https://arxiv.org/html/2402.01030v2"}}
```
- Add a task to the root_task:
```json
{"action": "add_task", "args": {"task": "Implement feature X"}}
```
- Update a task in the root_task:
```json
{"action": "modify_task", "args": {"id": "0", "state": "in_progress", "thought": ""}}
```
- Change the agent's state:
```json
{"action": "change_agent_state", "args": {"state": "paused"}}
```
- Finish the task:
```json
{"action": "finish", "args": {}}
```
"""
# If it's an init, we do it here.
action = data.get('action', '')
if action == ActionType.INIT:
await init_connection(connection_id, data)
return
logger.info(f'sio:oh_action:{connection_id}')
session = session_manager.get_local_session(connection_id)
await session.dispatch(data)
async def init_connection(connection_id: str, data: dict):
gh_token = data.pop('gh_token', None)
if not await authenticate_github_user(gh_token):
raise RuntimeError(status.WS_1008_POLICY_VIOLATION)
token = data.pop('token', None)
if token:
sid = get_sid_from_token(token, config.jwt_secret)
if sid == '':
await sio.send({'error': 'Invalid token', 'error_code': 401})
return
logger.info(f'Existing session: {sid}')
else:
sid = connection_id
logger.info(f'New session: {sid}')
token = sign_token({'sid': sid}, config.jwt_secret)
await sio.emit('oh_event', {'token': token, 'status': 'ok'}, to=connection_id)
latest_event_id = int(data.pop('latest_event_id', -1))
# The session in question should exist, but may not actually be running locally...
session = await session_manager.init_or_join_local_session(
sio, sid, connection_id, data
)
# Send events
async_stream = AsyncEventStreamWrapper(
session.agent_session.event_stream, latest_event_id + 1
)
async for event in async_stream:
if isinstance(
event,
(
NullAction,
NullObservation,
ChangeAgentStateAction,
),
):
continue
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
@sio.event
async def disconnect(connection_id: str):
logger.info(f'sio:disconnect:{connection_id}')
await session_manager.disconnect_from_local_session(connection_id)

View File

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

View File

@@ -0,0 +1,242 @@
import asyncio
import json
import os
import pickle
from asyncio import Task
from typing import Any, Dict, Optional
import asyncpg
from asyncpg.exceptions import PostgresError
from asyncpg.pool import Pool
from google.cloud.sql.connector import Connector
from socketio.async_pubsub_manager import AsyncPubSubManager
def has_binary(obj: Any, to_json: bool = False) -> bool:
if not obj or not isinstance(obj, (dict, list, bytes, bytearray, memoryview)):
return False
if isinstance(obj, (bytes, bytearray, memoryview)):
return True
if isinstance(obj, list):
return any(has_binary(item) for item in obj)
if isinstance(obj, dict):
return any(has_binary(v) for v in obj.values())
if hasattr(obj, 'to_json') and callable(obj.to_json) and not to_json:
return has_binary(obj.to_json(), True)
return False
class AsyncPostgresAdapter(AsyncPubSubManager):
def __init__(
self,
channel: str = 'socketio',
table_name: str = 'socket_io_attachments',
payload_threshold: int = 8000,
cleanup_interval: int = 30000,
):
self.channel = channel
super().__init__(channel=channel)
self.table_name = table_name
self.payload_threshold = payload_threshold
self.cleanup_interval = cleanup_interval
self._cleanup_timer: Optional[Task] = None
self._client: Optional[asyncpg.Connection] = None
self.pool: Optional[Pool] = None
# Connection configs
self.db_host = os.environ.get('DB_HOST')
self.db_user = os.environ.get('DB_USER')
self.db_pass = os.environ.get('DB_PASS', '').strip()
self.db_name = os.environ.get('DB_NAME')
# GCP configs
self.gcp_instance = os.environ.get('GCP_DB_INSTANCE')
self.gcp_project = os.environ.get('GCP_PROJECT')
self.gcp_region = os.environ.get('GCP_REGION')
self.connector = Connector() if self.gcp_instance else None
async def setup(self):
if not self.pool:
self.pool = await self._create_pool()
await self._create_db()
await self._init_client()
async def _create_pool(self) -> Pool:
if self.gcp_instance:
return await asyncpg.create_pool(
lambda: self._get_gcp_connection(self.db_name)
)
else:
return await asyncpg.create_pool(
user=self.db_user,
password=self.db_pass,
host=self.db_host,
database=self.db_name,
)
async def _get_gcp_connection(
self, db_name: Optional[str] = None
) -> asyncpg.Connection:
instance_string = f'{self.gcp_project}:{self.gcp_region}:{self.gcp_instance}'
conn = await self.connector.connect_async(
instance_connection_string=instance_string,
driver='asyncpg',
user=self.db_user,
password=self.db_pass,
db=db_name or self.db_name,
)
return conn
async def _init_client(self) -> None:
if not self.pool:
raise RuntimeError('Pool not initialized')
try:
self._client = await self.pool.acquire()
await self._client.execute(f'LISTEN "{self.channel}"')
self._client.add_listener(self.channel, self._on_notification)
if not self._cleanup_timer:
self._schedule_cleanup()
except PostgresError as e:
self.logger.error(f'Error initializing client: {e}')
await asyncio.sleep(2)
await self._init_client()
def _schedule_cleanup(self) -> None:
async def cleanup() -> None:
if not self.pool:
return
try:
await self.pool.execute(
f"DELETE FROM {self.table_name} WHERE created_at < now() - interval '{self.cleanup_interval} milliseconds'"
)
except PostgresError as e:
self.logger.error(f'Cleanup error: {e}')
self._cleanup_timer = asyncio.create_task(cleanup())
await asyncio.sleep(self.cleanup_interval / 1000)
self._cleanup_timer = asyncio.create_task(cleanup())
async def _publish_with_attachment(self, data: Dict) -> None:
if not self.pool:
raise RuntimeError('Pool not initialized')
payload = pickle.dumps(data)
result = await self.pool.fetchrow(
f'INSERT INTO {self.table_name} (payload) VALUES ($1) RETURNING id', payload
)
if not result:
raise RuntimeError('Failed to insert payload')
notification = {
'uid': self.uid,
'type': data['type'],
'attachmentId': result['id'],
}
await self.pool.execute(
'SELECT pg_notify($1, $2)', self.channel, json.dumps(notification)
)
async def _publish(self, data: Dict) -> None:
if not self.pool:
raise RuntimeError('Pool not initialized')
try:
data['uid'] = self.uid
if has_binary(data) or len(json.dumps(data)) > self.payload_threshold:
await self._publish_with_attachment(data)
return
await self.pool.execute(
'SELECT pg_notify($1, $2)', self.channel, json.dumps(data)
)
except PostgresError as e:
self.logger.error(f'Publish error: {e}')
raise
async def _on_notification(self, conn, pid, channel, payload) -> None:
if not self.pool:
return
try:
data = json.loads(payload)
if data.get('uid') == self.uid:
return
if 'attachmentId' in data:
result = await self.pool.fetchrow(
f'SELECT payload FROM {self.table_name} WHERE id = $1',
data['attachmentId'],
)
if not result:
self.logger.error(f"Attachment {data['attachmentId']} not found")
return
data = pickle.loads(result['payload'])
await self._handle_message(data)
except Exception as e:
self.logger.error(f'Notification error: {e}')
async def close(self) -> None:
if self._cleanup_timer:
self._cleanup_timer.cancel()
if self._client and self.pool:
await self.pool.release(self._client)
if self.connector:
await self.connector.close_async()
await super().close()
async def _create_db(self) -> None:
try:
# Connect to default postgres DB first
if self.gcp_instance:
sys_conn = await self._get_gcp_connection('postgres')
else:
sys_conn = await asyncpg.connect(
user=self.db_user,
password=self.db_pass,
host=self.db_host,
database='postgres',
)
try:
# Create DB if needed
exists = await sys_conn.fetchval(
'SELECT 1 FROM pg_database WHERE datname = $1', self.db_name
)
if not exists:
await sys_conn.execute(f'CREATE DATABASE "{self.db_name}"')
finally:
await sys_conn.close()
# Create attachments table
if not self.pool:
raise RuntimeError('Pool not initialized')
await self.pool.execute(f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
id SERIAL PRIMARY KEY,
payload BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
except Exception as e:
self.logger.error(f'Database creation error: {e}')
raise

View File

@@ -189,6 +189,7 @@ class AgentSession:
sid=self.sid,
plugins=agent.sandbox_plugins,
status_callback=self._status_callback,
headless_mode=False,
)
try:

View File

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

View File

@@ -1,14 +1,13 @@
import asyncio
import time
from dataclasses import dataclass, field
from typing import Optional
from fastapi import WebSocket
import socketio
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 +17,58 @@ 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(
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)
local_sessions_by_sid: dict[str, Session] = field(default_factory=dict)
local_sessions_by_connection_id: dict[str, Session] = field(default_factory=dict)
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)
async def init_or_join_local_session(self, sio: socketio.AsyncServer, sid: str, connection_id: str, data: dict):
""" If there is no local session running, initialize one """
session = self.local_sessions_by_sid.get(sid)
if not session:
session = Session(
sid=sid, file_store=self.file_store, config=self.config, sio=sio, ws=None
)
session.connect(connection_id)
self.local_sessions_by_sid[sid] = session
self.local_sessions_by_connection_id[connection_id] = session
await session.initialize_agent(data)
else:
session.connect(connection_id)
self.local_sessions_by_connection_id[connection_id] = session
return session
def get_local_session(self, connection_id: str) -> Session:
return self.local_sessions_by_connection_id[connection_id]
async def disconnect_from_local_session(self, connection_id: str):
session = self.local_sessions_by_connection_id.pop(connection_id, None)
if not session:
# This can occur if the init action was never run.
logger.warning(f'disconnect_from_uninitialized_session:{connection_id}')
return
if session.disconnect(connection_id):
asyncio.create_task(self._check_and_close_session(session))
async def _check_and_close_session(self, session: Session):
# Once there have been no connections to a session for a reasonable period, we close it
try:
await asyncio.sleep(15)
finally:
# If the sleep was cancelled, we still want to close these
if not session.connection_ids:
session.close()
self.local_sessions_by_sid.pop(session.sid)

View File

@@ -1,6 +1,7 @@
import asyncio
import time
import socketio
from fastapi import WebSocket, WebSocketDisconnect
from openhands.controller.agent import Agent
@@ -21,24 +22,32 @@ 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.async_utils import wait_all
class Session:
sid: str
websocket: WebSocket | None
sio: socketio.AsyncServer | None
connection_ids: set[str]
last_active_ts: int = 0
is_alive: bool = True
agent_session: AgentSession
loop: asyncio.AbstractEventLoop
def __init__(
self, sid: str, ws: WebSocket | None, config: AppConfig, file_store: FileStore
self,
sid: str,
ws: WebSocket | None,
config: AppConfig,
file_store: FileStore,
sio: socketio.AsyncServer | None,
):
self.sid = sid
self.websocket = ws
self.sio = sio
self.last_active_ts = int(time.time())
self.agent_session = AgentSession(
sid, file_store, status_callback=self.queue_status_message
@@ -47,31 +56,21 @@ class Session:
EventStreamSubscriber.SERVER, self.on_event, self.sid
)
self.config = config
self.connection_ids = set()
self.loop = asyncio.get_event_loop()
def connect(self, connection_id: str):
self.connection_ids.add(connection_id)
def disconnect(self, connection_id: str) -> bool:
self.connection_ids.remove(connection_id)
return not self.connection_ids
def close(self):
self.is_alive = False
self.agent_session.close()
async def loop_recv(self):
try:
if self.websocket is None:
return
while should_continue():
try:
data = await self.websocket.receive_json()
except ValueError:
await self.send_error('Invalid JSON')
continue
await self.dispatch(data)
except WebSocketDisconnect:
logger.info('WebSocket disconnected, sid: %s', self.sid)
self.close()
except RuntimeError as e:
logger.exception('Error in loop_recv: %s', e)
self.close()
async def _initialize_agent(self, data: dict):
async def initialize_agent(self, data: dict):
self.agent_session.event_stream.add_event(
ChangeAgentStateAction(AgentState.LOADING), EventSource.ENVIRONMENT
)
@@ -159,7 +158,7 @@ class Session:
async def dispatch(self, data: dict):
action = data.get('action', '')
if action == ActionType.INIT:
await self._initialize_agent(data)
await self.initialize_agent(data)
return
event = event_from_dict(data.copy())
# This checks if the model supports images
@@ -180,9 +179,15 @@ class Session:
async def send(self, data: dict[str, object]) -> bool:
try:
if self.websocket is None or not self.is_alive:
if not self.is_alive:
return False
await self.websocket.send_json(data)
if self.websocket:
await self.websocket.send_json(data)
if self.sio:
await wait_all(
self.sio.emit('oh_event', data, to=connection_id)
for connection_id in self.connection_ids
)
await asyncio.sleep(0.001) # This flushes the data to the client
self.last_active_ts = int(time.time())
return True

View File

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

View File

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

1133
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.13.0"
version = "0.13.1"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -63,6 +63,10 @@ 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"
python-socketio = "^5.11.4"
asyncpg = "^0.30.0"
cloud-sql-python-connector = "^1.13.0"
[tool.poetry.group.llama-index.dependencies]
llama-index = "*"

View File

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

View File

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

View File

@@ -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}.'

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
from unittest.mock import MagicMock
import pytest
from openhands.controller.agent_controller import AgentController
from openhands.events import EventSource
from openhands.events.action import CmdRunAction, MessageAction
from openhands.events.observation import CmdOutputObservation
@pytest.fixture
def mock_event_stream():
stream = MagicMock()
# Mock get_events to return an empty list by default
stream.get_events.return_value = []
return stream
@pytest.fixture
def mock_agent():
agent = MagicMock()
agent.llm = MagicMock()
agent.llm.config = MagicMock()
return agent
class TestTruncation:
def test_apply_conversation_window_basic(self, mock_event_stream, mock_agent):
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_truncation',
confirmation_mode=False,
headless_mode=True,
)
# Create a sequence of events with IDs
first_msg = MessageAction(content='Hello, start task', wait_for_response=False)
first_msg._source = EventSource.USER
first_msg._id = 1
cmd1 = CmdRunAction(command='ls')
cmd1._id = 2
obs1 = CmdOutputObservation(command='ls', content='file1.txt', command_id=2)
obs1._id = 3
obs1._cause = 2
cmd2 = CmdRunAction(command='pwd')
cmd2._id = 4
obs2 = CmdOutputObservation(command='pwd', content='/home', command_id=4)
obs2._id = 5
obs2._cause = 4
events = [first_msg, cmd1, obs1, cmd2, obs2]
# Apply truncation
truncated = controller._apply_conversation_window(events)
# Should keep first user message and roughly half of other events
assert (
len(truncated) >= 3
) # First message + at least one action-observation pair
assert truncated[0] == first_msg # First message always preserved
assert controller.state.start_id == first_msg._id
assert controller.state.truncation_id is not None
# Verify pairs aren't split
for i, event in enumerate(truncated[1:]):
if isinstance(event, CmdOutputObservation):
assert any(e._id == event._cause for e in truncated[: i + 1])
def test_context_window_exceeded_handling(self, mock_event_stream, mock_agent):
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_truncation',
confirmation_mode=False,
headless_mode=True,
)
# Setup initial history with IDs
first_msg = MessageAction(content='Start task', wait_for_response=False)
first_msg._source = EventSource.USER
first_msg._id = 1
# Add agent question
agent_msg = MessageAction(
content='What task would you like me to perform?', wait_for_response=True
)
agent_msg._source = EventSource.AGENT
agent_msg._id = 2
# Add user response
user_response = MessageAction(
content='Please list all files and show me current directory',
wait_for_response=False,
)
user_response._source = EventSource.USER
user_response._id = 3
cmd1 = CmdRunAction(command='ls')
cmd1._id = 4
obs1 = CmdOutputObservation(command='ls', content='file1.txt', command_id=4)
obs1._id = 5
obs1._cause = 4
# Update mock event stream to include new messages
mock_event_stream.get_events.return_value = [
first_msg,
agent_msg,
user_response,
cmd1,
obs1,
]
controller.state.history = [first_msg, agent_msg, user_response, cmd1, obs1]
original_history_len = len(controller.state.history)
# Simulate ContextWindowExceededError and truncation
controller.state.history = controller._apply_conversation_window(
controller.state.history
)
# Verify truncation occurred
assert len(controller.state.history) < original_history_len
assert controller.state.start_id == first_msg._id
assert controller.state.truncation_id is not None
assert controller.state.truncation_id > controller.state.start_id
def test_history_restoration_after_truncation(self, mock_event_stream, mock_agent):
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_truncation',
confirmation_mode=False,
headless_mode=True,
)
# Create events with IDs
first_msg = MessageAction(content='Start task', wait_for_response=False)
first_msg._source = EventSource.USER
first_msg._id = 1
events = [first_msg]
for i in range(5):
cmd = CmdRunAction(command=f'cmd{i}')
cmd._id = i + 2
obs = CmdOutputObservation(
command=f'cmd{i}', content=f'output{i}', command_id=cmd._id
)
obs._cause = cmd._id
events.extend([cmd, obs])
# Set up initial history
controller.state.history = events.copy()
# Force truncation
controller.state.history = controller._apply_conversation_window(
controller.state.history
)
# Save state
saved_start_id = controller.state.start_id
saved_truncation_id = controller.state.truncation_id
saved_history_len = len(controller.state.history)
# Set up mock event stream for new controller
mock_event_stream.get_events.return_value = controller.state.history
# Create new controller with saved state
new_controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_truncation',
confirmation_mode=False,
headless_mode=True,
)
new_controller.state.start_id = saved_start_id
new_controller.state.truncation_id = saved_truncation_id
new_controller.state.history = mock_event_stream.get_events()
# Verify restoration
assert len(new_controller.state.history) == saved_history_len
assert new_controller.state.history[0] == first_msg
assert new_controller.state.start_id == saved_start_id