Compare commits

...

15 Commits

Author SHA1 Message Date
Xingyao Wang 264d24f750 add debug statement 2025-02-18 16:25:42 -05:00
Xingyao Wang 1a7003a705 Add sysbox support to remote runtime for eval; Add memory monitor, stress tests to help debug memory issue (#6684)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-02-18 20:02:28 +00:00
Calvin Smith 8d097efb4f enh: Refactor Event -> Message pipeline outside of CodeActAgent (#6715)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-18 11:23:06 -07:00
sp.wack 2e98fc8fb3 feat(SaaS): Billing settings screen (#6495)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-02-18 18:56:10 +01:00
sp.wack e3e00ed70a fix(frontend): Hide modal when in settings page if first time (#6792) 2025-02-18 21:28:59 +04:00
sp.wack 96d1992823 hotfix: Conversation panel toggle should change color given state (#6791) 2025-02-18 11:38:26 -05:00
dependabot[bot] 7a3a0d8c0c chore(deps): bump the version-all group across 1 directory with 9 updates (#6783)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-18 16:09:30 +00:00
sp.wack fdffca18e0 hotfix(frontend): Input set/unset state and disable runtime input (#6788) 2025-02-18 20:01:39 +04:00
sp.wack b10416e0a3 hotfix: Consistent background color (#6786) 2025-02-18 19:38:01 +04:00
nottherealironman 1f462d2417 docs: add guide for minimum computing and storage requirements (#6575) 2025-02-18 14:20:35 +00:00
tofarr 0a6ff463db CSS Fixes (#6770) 2025-02-18 12:10:07 +00:00
Rohit Malhotra 9ff15bf94f Add selected branch to convo metadata (#6773) 2025-02-17 17:27:13 -05:00
mamoodi 6c48013601 Update OpenHands Cloud docs with correct permissions and instructions (#6774) 2025-02-17 21:48:57 +00:00
Graham Neubig 07fcb786af Upgrade tree sitter (#6740)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-17 20:14:26 +01:00
Rohit Malhotra ce42e22105 [Docs]: Cloud Openhands (#6747)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-02-17 18:39:49 +00:00
77 changed files with 4506 additions and 3232 deletions
@@ -0,0 +1,21 @@
# Cloud GitHub Resolver
The GitHub Resolver automates code fixes and provides intelligent assistance for your repositories.
## Setup
The Cloud Github Resolver is available automatically when you
[grant OpenHands Cloud repository access](./openhands-cloud.md#adding-repositories).
## Usage
### Issues
On your repository, label an issue with `openhands`. OpenHands will attempt to fix the issue.
### Pull Requests
In order to get OpenHands to work on pull requests, use `@openhands` in top level or single inline comments to:
- Ask questions
- Request updates
- Get code explanations
@@ -0,0 +1,39 @@
# Openhands Cloud
OpenHands Cloud is the cloud hosted version of OpenHands by All Hands AI.
## Accessing OpenHands Cloud
Currently, users are being admitted to access OpenHands Cloud in waves. To sign up,
[join the waitlist](https://www.all-hands.dev/join-waitlist). Once you are approved, you will get an email with
instructions on how to access it.
## Getting Started
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
1. After reading and accepting the terms of service, click `Connect to GitHub`.
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands by All Hands AI`.
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
you can click the `Learn more` link on the GitHub authorize page.
## Adding Repositories
You can grant OpenHands specific repository access:
1. Under the `Select a GitHub project` dropdown, select `Add more repositories...`.
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
- Openhands requests short-lived tokens (8-hour expiry) with these permissions:
- Actions: Read and write
- Administration: Read-only
- Commit statuses: Read and write
- Contents: Read and write
- Issues: Read and write
- Metadata: Read-only
- Pull requests: Read and write
- Webhooks: Read and write
- Workflows: Read and write
- Repository access for a user is granted based on:
- Granted permission for the repository.
- User's GitHub permissions (owner/collaborator).
You can manage repository access any time by following the above workflow or visiting the Settings page and selecting
`Configure GitHub Repositories` under the `GitHub Settings` section.
+2
View File
@@ -6,6 +6,8 @@
- Linux
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
## Prerequisites
<details>
+19 -2
View File
@@ -42,7 +42,7 @@ const sidebars: SidebarsConfig = {
id: 'usage/prompting/microagents-public',
},
],
}
},
],
},
{
@@ -69,6 +69,23 @@ const sidebars: SidebarsConfig = {
label: 'Github Actions',
id: 'usage/how-to/github-action',
},
{
type: 'category',
label: 'Cloud',
items: [
{
type: 'doc',
label: 'Openhands Cloud',
id: 'usage/cloud/openhands-cloud',
},
{
type: 'doc',
label: 'Cloud GitHub Resolver',
id: 'usage/cloud/cloud-github-resolver',
},
],
},
],
},
{
@@ -185,7 +202,7 @@ const sidebars: SidebarsConfig = {
type: 'doc',
label: 'About',
id: 'usage/about',
}
},
],
};
+4 -7
View File
@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -17,7 +18,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -60,17 +60,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=False,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+5 -11
View File
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -40,21 +40,15 @@ from openhands.utils.async_utils import call_async_from_sync
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-slim'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-slim',
enable_auto_lint=True,
use_host_network=False,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -12
View File
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -24,7 +25,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
load_from_toml,
parse_arguments,
@@ -47,22 +47,14 @@ SKIP_NUM = (
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.11-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.11-bookworm',
enable_auto_lint=True,
use_host_network=False,
timeout=100,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=1800,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -7
View File
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -22,7 +23,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -57,18 +57,15 @@ def get_config(
metadata: EvalMetadata,
) -> AppConfig:
BIOCODER_BENCH_CONTAINER_IMAGE = 'public.ecr.aws/i5g0m1f6/eval_biocoder:v1.0'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = BIOCODER_BENCH_CONTAINER_IMAGE
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image=BIOCODER_BENCH_CONTAINER_IMAGE,
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+5 -7
View File
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -71,17 +71,15 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -18,7 +19,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -36,17 +36,14 @@ def get_config(
assert (
metadata.max_iterations == 1
), 'max_iterations must be 1 for browsing delegation evaluation.'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=False,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
workspace_base=None,
workspace_mount_path=None,
)
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
EvalOutput,
assert_and_raise,
codeact_user_response,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -105,9 +105,7 @@ def get_config(
instance: pd.Series,
metadata: EvalMetadata,
) -> AppConfig:
# COMMIT0_CONTAINER_IMAGE = 'wentingzhao/'
assert USE_INSTANCE_IMAGE
# We use a different instance image for the each instance of commit0 eval
repo_name = instance['repo'].split('/')[1]
base_container_image = get_instance_docker_image(repo_name)
logger.info(
@@ -115,28 +113,16 @@ def get_config(
f'Please make sure this image exists. '
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
)
# else:
# raise
# base_container_image = SWE_BENCH_CONTAINER_IMAGE
# logger.info(f'Using swe-bench container image: {base_container_image}')
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -62,17 +62,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -7
View File
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -21,7 +22,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -48,17 +48,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -7
View File
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -19,7 +20,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -40,17 +40,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -7
View File
@@ -29,6 +29,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -37,7 +38,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -61,17 +61,14 @@ ACTION_FORMAT = """
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -22,6 +22,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -30,7 +31,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -82,17 +82,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -17,7 +18,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -45,18 +45,18 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-logic-reasoning:v1.0'
sandbox_config.runtime_extra_deps = (
'$OH_INTERPRETER_PATH -m pip install scitools-pyke'
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-logic-reasoning:v1.0',
enable_auto_lint=True,
use_host_network=False,
runtime_extra_deps='$OH_INTERPRETER_PATH -m pip install scitools-pyke',
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -13
View File
@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -21,7 +22,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -55,23 +55,14 @@ def get_config(
metadata: EvalMetadata,
env_id: str,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-miniwob:v1.0'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
enable_auto_lint=True,
use_host_network=False,
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'),
remote_runtime_init_timeout=1800,
keep_runtime_alive=False,
timeout=120,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+8 -8
View File
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -22,7 +23,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -103,18 +103,18 @@ def load_incontext_example(task_name: str, with_tool: bool = True):
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-mint:v1.0'
sandbox_config.runtime_extra_deps = (
f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}'
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-mint:v1.0',
enable_auto_lint=True,
use_host_network=False,
runtime_extra_deps=f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}',
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -6
View File
@@ -25,6 +25,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -33,7 +34,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
load_app_config,
@@ -77,16 +77,14 @@ ID2CONDA = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'public.ecr.aws/i5g0m1f6/ml-bench'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='public.ecr.aws/i5g0m1f6/ml-bench',
enable_auto_lint=True,
use_host_network=False,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -20,7 +21,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -59,22 +59,17 @@ def get_config(
metadata: EvalMetadata,
instance_id: str,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = (
'docker.io/xingyaoww/openhands-eval-scienceagentbench'
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_budget_per_task=4,
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='docker.io/xingyaoww/openhands-eval-scienceagentbench',
enable_auto_lint=True,
use_host_network=False,
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+19 -19
View File
@@ -1,5 +1,6 @@
import json
import os
import subprocess
import tempfile
import time
from functools import partial
@@ -21,13 +22,14 @@ from evaluation.benchmarks.swe_bench.run_infer import get_instance_docker_image
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
)
from openhands.core.config import (
AppConfig,
SandboxConfig,
LLMConfig,
get_parser,
)
from openhands.core.logger import openhands_logger as logger
@@ -79,22 +81,16 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> AppConfig:
f'Please make sure this image exists. '
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
)
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
sandbox_config.remote_runtime_resource_factor = get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
)
config = AppConfig(
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=600,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=3600,
remote_runtime_resource_factor=get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
),
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -415,13 +411,17 @@ if __name__ == '__main__':
else:
# Initialize with a dummy metadata when file doesn't exist
metadata = EvalMetadata(
agent_class="dummy_agent", # Placeholder agent class
llm_config=LLMConfig(model="dummy_model"), # Minimal LLM config
agent_class='dummy_agent', # Placeholder agent class
llm_config=LLMConfig(model='dummy_model'), # Minimal LLM config
max_iterations=1, # Minimal iterations
eval_output_dir=os.path.dirname(args.input_file), # Use input file dir as output dir
eval_output_dir=os.path.dirname(
args.input_file
), # Use input file dir as output dir
start_time=time.strftime('%Y-%m-%d %H:%M:%S'), # Current time
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip(), # Current commit
dataset=args.dataset # Dataset name from args
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'])
.decode('utf-8')
.strip(), # Current commit
dataset=args.dataset, # Dataset name from args
)
# The evaluation harness constrains the signature of `process_instance_func` but we need to
@@ -1 +0,0 @@
{"pydata__xarray-6721": 8, "pytest-dev__pytest-7236": 8, "matplotlib__matplotlib-24627": 4, "django__django-15561": 4, "django__django-15098": 4, "django__django-14771": 4, "sympy__sympy-21612": 4, "sympy__sympy-15345": 4, "psf__requests-5414": 4, "astropy__astropy-14508": 2, "django__django-11451": 2, "django__django-11477": 2, "django__django-10880": 2, "django__django-11163": 2, "django__django-11815": 2, "astropy__astropy-14369": 2, "django__django-10097": 2, "django__django-10554": 2, "django__django-12304": 2, "django__django-12325": 2, "django__django-11551": 2, "django__django-11734": 2, "django__django-13109": 2, "django__django-13089": 2, "django__django-13343": 2, "django__django-13363": 2, "django__django-13809": 2, "django__django-13810": 2, "django__django-13786": 2, "django__django-13807": 2, "django__django-14493": 2, "django__django-11820": 2, "django__django-11951": 2, "django__django-11964": 2, "astropy__astropy-14309": 2, "astropy__astropy-14365": 2, "astropy__astropy-12907": 2, "astropy__astropy-14182": 2, "django__django-15161": 2, "django__django-15128": 2, "django__django-14999": 2, "django__django-14915": 2, "django__django-14752": 2, "django__django-14765": 2, "django__django-14089": 2, "django__django-15252": 2, "django__django-15380": 2, "django__django-15382": 2, "django__django-15499": 2, "django__django-15467": 2, "django__django-15280": 2, "django__django-15315": 2, "django__django-15277": 2, "django__django-15268": 2, "django__django-15629": 2, "django__django-15695": 2, "django__django-15732": 2, "django__django-15863": 2, "django__django-16082": 2, "django__django-16145": 2, "django__django-16256": 2, "django__django-16429": 2, "django__django-16454": 2, "django__django-16493": 2, "matplotlib__matplotlib-13989": 2, "matplotlib__matplotlib-20488": 2, "django__django-15503": 2, "django__django-15525": 2, "django__django-15375": 2, "django__django-15278": 2, "matplotlib__matplotlib-21568": 2, "matplotlib__matplotlib-20859": 2, "matplotlib__matplotlib-20826": 2, "matplotlib__matplotlib-20676": 2, "matplotlib__matplotlib-23412": 2, "matplotlib__matplotlib-22719": 2, "matplotlib__matplotlib-23299": 2, "matplotlib__matplotlib-22865": 2, "matplotlib__matplotlib-24149": 2, "matplotlib__matplotlib-24177": 2, "matplotlib__matplotlib-24570": 2, "matplotlib__matplotlib-24637": 2, "matplotlib__matplotlib-24970": 2, "matplotlib__matplotlib-23476": 2, "matplotlib__matplotlib-24026": 2, "matplotlib__matplotlib-23314": 2, "matplotlib__matplotlib-25332": 2, "matplotlib__matplotlib-25311": 2, "matplotlib__matplotlib-25122": 2, "matplotlib__matplotlib-25479": 2, "matplotlib__matplotlib-26342": 2, "psf__requests-2317": 2, "matplotlib__matplotlib-25960": 2, "matplotlib__matplotlib-25775": 2, "pydata__xarray-4356": 2, "pydata__xarray-4075": 2, "pydata__xarray-6461": 2, "pydata__xarray-4687": 2, "pydata__xarray-6599": 2, "pylint-dev__pylint-4661": 2, "django__django-15554": 2, "django__django-15563": 2, "pytest-dev__pytest-5262": 2, "pytest-dev__pytest-10081": 2, "scikit-learn__scikit-learn-12973": 2, "scikit-learn__scikit-learn-13124": 2, "scikit-learn__scikit-learn-13779": 2, "scikit-learn__scikit-learn-14141": 2, "scikit-learn__scikit-learn-13439": 2, "scikit-learn__scikit-learn-13496": 2, "scikit-learn__scikit-learn-15100": 2, "scikit-learn__scikit-learn-25102": 2, "scikit-learn__scikit-learn-25232": 2, "scikit-learn__scikit-learn-25747": 2, "scikit-learn__scikit-learn-26323": 2, "scikit-learn__scikit-learn-9288": 2, "scikit-learn__scikit-learn-14496": 2, "scikit-learn__scikit-learn-14629": 2, "sphinx-doc__sphinx-8265": 2, "sphinx-doc__sphinx-8548": 2, "sphinx-doc__sphinx-8593": 2, "sphinx-doc__sphinx-8595": 2, "sphinx-doc__sphinx-8621": 2, "sphinx-doc__sphinx-8638": 2, "sphinx-doc__sphinx-9229": 2, "sphinx-doc__sphinx-9281": 2, "sphinx-doc__sphinx-9461": 2, "sphinx-doc__sphinx-9591": 2, "sphinx-doc__sphinx-9658": 2, "sphinx-doc__sphinx-9673": 2, "sympy__sympy-12096": 2, "sympy__sympy-12481": 2, "sphinx-doc__sphinx-10323": 2, "sphinx-doc__sphinx-7590": 2, "sympy__sympy-13877": 2, "sympy__sympy-12489": 2, "sympy__sympy-15809": 2, "sympy__sympy-14711": 2, "sympy__sympy-16597": 2, "sympy__sympy-16766": 2, "sympy__sympy-16792": 2, "sympy__sympy-15875": 2, "sympy__sympy-17655": 2, "sympy__sympy-18189": 2, "sympy__sympy-18763": 2, "sympy__sympy-19040": 2, "sympy__sympy-19495": 2, "sympy__sympy-19637": 2, "sympy__sympy-19783": 2, "sympy__sympy-17630": 2, "sympy__sympy-20428": 2, "sympy__sympy-20590": 2, "sympy__sympy-20801": 2, "sympy__sympy-21379": 2, "sympy__sympy-21847": 2, "sympy__sympy-22456": 2, "sympy__sympy-22714": 2, "sympy__sympy-22914": 2, "sympy__sympy-23262": 2, "sympy__sympy-23413": 2, "sympy__sympy-23534": 2, "sympy__sympy-24066": 2, "sympy__sympy-24213": 2, "sympy__sympy-24443": 2, "sympy__sympy-24562": 2, "sympy__sympy-24661": 2}
+29 -20
View File
@@ -18,6 +18,7 @@ from evaluation.utils.shared import (
EvalOutput,
assert_and_raise,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
is_fatal_evaluation_error,
make_metadata,
@@ -30,7 +31,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -122,30 +122,23 @@ def get_config(
base_container_image = SWE_BENCH_CONTAINER_IMAGE
logger.info(f'Using swe-bench container image: {base_container_image}')
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
sandbox_config.enable_auto_lint = True
sandbox_config.use_host_network = False
# Add platform to the sandbox config to solve issue 4401
sandbox_config.platform = 'linux/amd64'
sandbox_config.remote_runtime_resource_factor = get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
# Add platform to the sandbox config to solve issue 4401
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_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_api_timeout=120,
remote_runtime_resource_factor=get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
),
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -331,6 +324,22 @@ def complete_runtime(
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
if obs.exit_code == -1:
# The previous command is still running
# We need to kill previous command
logger.info('The previous command is still running, trying to kill it...')
action = CmdRunAction(command='C-c')
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Then run the command again
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
isinstance(obs, CmdOutputObservation) and obs.exit_code == 0,
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
@@ -13,11 +13,11 @@ from typing import List
import yaml
from browsing import pre_login
from evaluation.utils.shared import get_default_sandbox_config_for_eval
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
LLMConfig,
SandboxConfig,
get_agent_config_arg,
get_llm_config_arg,
get_parser,
@@ -38,6 +38,8 @@ def get_config(
llm_config: LLMConfig,
agent_config: AgentConfig | None,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
config = AppConfig(
run_as_openhands=False,
max_budget_per_task=4,
@@ -45,16 +47,7 @@ def get_config(
save_trajectory_path=os.path.join(
mount_path_on_host, f'traj_{task_short_name}.json'
),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
# using host network to access the host machine from the container
use_host_network=True,
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# we mount trajectories path so that trajectories, generated by OpenHands
# controller, can be accessible to the evaluator file in the runtime container
workspace_mount_path=mount_path_on_host,
+4 -7
View File
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -18,7 +19,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -41,17 +41,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -20,7 +21,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -55,32 +55,29 @@ def get_config(
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
sandbox_config.browsergym_eval_env = env_id
sandbox_config.runtime_startup_env_vars = {
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
}
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
},
timeout=300,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+16 -19
View File
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -19,7 +20,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -50,29 +50,26 @@ def get_config(
assert base_url is not None, 'WEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
sandbox_config.browsergym_eval_env = env_id
sandbox_config.runtime_startup_env_vars = {
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'SHOPPING': f'{base_url}:7770/',
'SHOPPING_ADMIN': f'{base_url}:7780/admin',
'REDDIT': f'{base_url}:9999',
'GITLAB': f'{base_url}:8023',
'WIKIPEDIA': f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing',
'MAP': f'{base_url}:3000',
'HOMEPAGE': f'{base_url}:4399',
}
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'SHOPPING': f'{base_url}:7770/',
'SHOPPING_ADMIN': f'{base_url}:7780/admin',
'REDDIT': f'{base_url}:9999',
'GITLAB': f'{base_url}:8023',
'WIKIPEDIA': f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing',
'MAP': f'{base_url}:3000',
'HOMEPAGE': f'{base_url}:4399',
},
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+4 -13
View File
@@ -8,6 +8,7 @@ from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestRes
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -21,7 +22,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -43,23 +43,14 @@ def get_config(
metadata: EvalMetadata,
instance_id: str,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.platform = 'linux/amd64'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
# use default base_container_image
enable_auto_lint=True,
use_host_network=False,
timeout=300,
# Add platform to the sandbox config to solve issue 4401
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_runtime_alive=False,
remote_runtime_init_timeout=3600,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
+16 -1
View File
@@ -16,7 +16,7 @@ from pydantic import BaseModel
from tqdm import tqdm
from openhands.controller.state.state import State
from openhands.core.config import LLMConfig
from openhands.core.config import LLMConfig, SandboxConfig
from openhands.core.config.agent_config import AgentConfig
from openhands.core.config.condenser_config import (
CondenserConfig,
@@ -555,3 +555,18 @@ def get_metrics(state: State) -> dict[str, Any]:
metrics = state.metrics.get() if state.metrics else {}
metrics['condenser'] = get_condensation_metadata(state)
return metrics
def get_default_sandbox_config_for_eval() -> SandboxConfig:
return SandboxConfig(
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_api_timeout=120,
remote_runtime_enable_retries=True,
remote_runtime_class='sysbox',
)
@@ -0,0 +1,166 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import OpenHands from "#/api/open-hands";
import { PaymentForm } from "#/components/features/payment/payment-form";
describe("PaymentForm", () => {
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const renderPaymentForm = () =>
render(<PaymentForm />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
beforeEach(() => {
// useBalance hook will return the balance only if the APP_MODE is "saas"
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the users current balance", async () => {
getBalanceSpy.mockResolvedValue("100.50");
renderPaymentForm();
await waitFor(() => {
const balance = screen.getByTestId("user-balance");
expect(balance).toHaveTextContent("$100.50");
});
});
it("should render the users current balance to two decimal places", async () => {
getBalanceSpy.mockResolvedValue("100");
renderPaymentForm();
await waitFor(() => {
const balance = screen.getByTestId("user-balance");
expect(balance).toHaveTextContent("$100.00");
});
});
test("the user can top-up a specific amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.12");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12);
});
it("should round the top-up amount to two decimal places", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.125456");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
});
it("should render the payment method link", async () => {
renderPaymentForm();
screen.getByTestId("payment-methods-link");
});
it("should disable the top-up button if the user enters an invalid amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpButton = screen.getByText("Add credit");
expect(topUpButton).toBeDisabled();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, " ");
expect(topUpButton).toBeDisabled();
});
it("should disable the top-up button after submission", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.12");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(topUpButton).toBeDisabled();
});
describe("prevent submission if", () => {
test("user enters a negative amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "-50.12");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters an empty string", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, " ");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters a non-numeric value", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "abc");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters less than the minimum amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "20"); // test assumes the minimum is 25
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { SettingsInput } from "#/components/features/settings/settings-input";
describe("SettingsInput", () => {
@@ -85,4 +86,24 @@ describe("SettingsInput", () => {
expect(screen.getByText("Start Content")).toBeInTheDocument();
});
it("should call onChange with the input value", async () => {
const onChangeMock = vi.fn();
const user = userEvent.setup();
render(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
onChange={onChangeMock}
/>,
);
const input = screen.getByTestId("test-input");
await user.type(input, "Test");
expect(onChangeMock).toHaveBeenCalledTimes(4);
expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
});
});
+10 -7
View File
@@ -39,12 +39,12 @@ describe("Home Screen", () => {
Component: Home,
path: "/",
},
{
Component: SettingsScreen,
path: "/settings",
},
],
},
{
Component: SettingsScreen,
path: "/settings",
},
]);
afterEach(() => {
@@ -96,6 +96,9 @@ describe("Home Screen", () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
@@ -104,11 +107,11 @@ describe("Home Screen", () => {
);
await user.click(advancedSettingsButton);
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
});
});
@@ -0,0 +1,83 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as FeatureFlags from "#/utils/feature-flags";
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
const RoutesStub = createRoutesStub([
{
Component: SettingsScreen,
path: "/settings",
children: [
{
Component: () => <PaymentForm />,
path: "/settings/billing",
},
],
},
]);
const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
afterEach(() => {
vi.clearAllMocks();
});
it("should not render the navbar if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
});
it("should render the navbar if SaaS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.getByTestId("settings-navbar");
within(navbar).getByText("Account");
within(navbar).getByText("Credits");
});
});
it("should render the billing settings if clicking the credits item", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).getByText("Credits");
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
within(billingSection).getByText("Manage Credits");
});
});
+88 -5
View File
@@ -10,6 +10,7 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { PostApiSettings } from "#/types/settings";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
@@ -36,6 +37,7 @@ describe("Settings Screen", () => {
{
Component: SettingsScreen,
path: "/settings",
children: [{ Component: AccountSettings, path: "/settings" }],
},
]);
@@ -76,7 +78,8 @@ describe("Settings Screen", () => {
});
});
it("should render an indicator if the GitHub token is not set", async () => {
// TODO: Set a better unset indicator
it.skip("should render an indicator if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
@@ -97,6 +100,20 @@ describe("Settings Screen", () => {
});
});
it("should set asterik placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.getByTestId("github-token-input");
expect(input).toHaveProperty("placeholder", "**********");
});
});
it("should render an indicator if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
@@ -314,7 +331,8 @@ describe("Settings Screen", () => {
// screen.getByTestId("security-analyzer-input");
});
it("should render an indicator if the LLM API key is not set", async () => {
// TODO: Set a better unset indicator
it.skip("should render an indicator if the LLM API key is not set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: null,
@@ -443,7 +461,22 @@ describe("Settings Screen", () => {
expect(input).toHaveValue("1x (2 core, 8G)");
});
it("should save the runtime settings when the 'Save Changes' button is clicked", async () => {
it("should always have the runtime input disabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await toggleAdvancedSettings(userEvent.setup());
const input = await screen.findByTestId("runtime-settings-input");
expect(input).toBeDisabled();
});
it.skip("should save the runtime settings when the 'Save Changes' button is clicked", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
@@ -665,7 +698,7 @@ describe("Settings Screen", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: undefined,
llm_api_key: "", // empty because it's not set previously
github_token: undefined,
language: "no",
}),
@@ -704,7 +737,7 @@ describe("Settings Screen", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
github_token: undefined,
llm_api_key: undefined,
llm_api_key: "", // empty because it's not set previously
llm_model: "openai/gpt-4o",
}),
);
@@ -869,5 +902,55 @@ describe("Settings Screen", () => {
}),
);
});
it("should send an empty LLM API Key if the user submits an empty string", async () => {
const user = userEvent.setup();
renderSettingsScreen();
const input = await screen.findByTestId("llm-api-key-input");
expect(input).toHaveValue("");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ llm_api_key: "" }),
);
});
it("should not send an empty LLM API Key if the user submits an empty string but already has it set", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
});
renderSettingsScreen();
const input = await screen.findByTestId("llm-api-key-input");
expect(input).toHaveValue("");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ llm_api_key: undefined }),
);
});
it("should submit the LLM API Key if it is the first time the user sets it", async () => {
const user = userEvent.setup();
renderSettingsScreen();
const input = await screen.findByTestId("llm-api-key-input");
await user.type(input, "new-api-key");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ llm_api_key: "new-api-key" }),
);
});
});
});
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { amountIsValid } from "#/utils/amount-is-valid";
describe("amountIsValid", () => {
describe("fails", () => {
test("when the amount is negative", () => {
expect(amountIsValid("-5")).toBe(false);
expect(amountIsValid("-25")).toBe(false);
});
test("when the amount is zero", () => {
expect(amountIsValid("0")).toBe(false);
});
test("when an empty string is passed", () => {
expect(amountIsValid("")).toBe(false);
expect(amountIsValid(" ")).toBe(false);
});
test("when a non-numeric value is passed", () => {
expect(amountIsValid("abc")).toBe(false);
expect(amountIsValid("1abc")).toBe(false);
expect(amountIsValid("abc1")).toBe(false);
});
test("when an amount less than the minimum is passed", () => {
// test assumes the minimum is 25
expect(amountIsValid("24")).toBe(false);
expect(amountIsValid("24.99")).toBe(false);
});
});
});
+166 -129
View File
@@ -14,26 +14,28 @@
"@react-router/serve": "^7.1.5",
"@react-types/shared": "^3.27.0",
"@reduxjs/toolkit": "^2.5.1",
"@tanstack/react-query": "^5.66.0",
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"@tanstack/react-query": "^5.66.7",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.4.2",
"framer-motion": "^12.4.4",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.3",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.22",
"jose": "^5.9.4",
"jose": "^5.10.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.217.6",
"posthog-js": "^1.219.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.4.0",
"react-i18next": "^15.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
@@ -41,7 +43,7 @@
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.0",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.0.1",
"vite": "^6.1.0",
@@ -66,7 +68,7 @@
"@types/ws": "^8.5.14",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/coverage-v8": "^3.0.6",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -84,6 +86,7 @@
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.1",
"stripe": "^17.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite-plugin-svgr": "^4.2.0",
@@ -5871,6 +5874,29 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz",
"integrity": "sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz",
"integrity": "sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"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",
@@ -6139,9 +6165,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.66.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.3.tgz",
"integrity": "sha512-+2iDxH7UFdtwcry766aJszGmbByQDIzTltJ3oQAZF9bhCxHCIN3yDwHa6qDCZxcpMGvUphCRx/RYJvLbM8mucQ==",
"version": "5.66.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz",
"integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -6149,12 +6175,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.66.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.3.tgz",
"integrity": "sha512-sWMvxZ5VugPDgD1CzP7f0s9yFvjcXP3FXO5IVV2ndXlYqUCwykU8U69Kk05Qn5UvGRqB/gtj4J7vcTC6vtLHtQ==",
"version": "5.66.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.7.tgz",
"integrity": "sha512-qd3q/tUpF2K1xItfPZddk1k/8pSXnovg41XyCqJgPoyYEirMBtB0sVEVVQ/CsAOngzgWtBPXimVf4q4kM9uO6A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.66.3"
"@tanstack/query-core": "5.66.4"
},
"funding": {
"type": "github",
@@ -6703,16 +6729,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.0.tgz",
"integrity": "sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==",
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz",
"integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.24.0",
"@typescript-eslint/types": "8.24.0",
"@typescript-eslint/typescript-estree": "8.24.0"
"@typescript-eslint/scope-manager": "8.24.1",
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/typescript-estree": "8.24.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6727,14 +6753,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz",
"integrity": "sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==",
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz",
"integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.24.0",
"@typescript-eslint/visitor-keys": "8.24.0"
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/visitor-keys": "8.24.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6745,9 +6771,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
"version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.0.tgz",
"integrity": "sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==",
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz",
"integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6759,14 +6785,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz",
"integrity": "sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==",
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz",
"integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.24.0",
"@typescript-eslint/visitor-keys": "8.24.0",
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/visitor-keys": "8.24.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -6786,13 +6812,13 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz",
"integrity": "sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==",
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz",
"integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.24.0",
"@typescript-eslint/types": "8.24.1",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@@ -6873,9 +6899,9 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz",
"integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz",
"integrity": "sha512-JRTlR8Bw+4BcmVTICa7tJsxqphAktakiLsAmibVLAWbu1lauFddY/tXeM6sAyl1cgkPuXtpnUgaCPhTdz1Qapg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6896,8 +6922,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.0.5",
"vitest": "3.0.5"
"@vitest/browser": "3.0.6",
"vitest": "3.0.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -6906,15 +6932,15 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz",
"integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz",
"integrity": "sha512-zBduHf/ja7/QRX4HdP1DSq5XrPgdN+jzLOwaTq/0qZjYfgETNFCKf9nOAp2j3hmom3oTbczuUzrzg9Hafh7hNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.0.5",
"@vitest/utils": "3.0.5",
"chai": "^5.1.2",
"@vitest/spy": "3.0.6",
"@vitest/utils": "3.0.6",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
@@ -6922,13 +6948,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz",
"integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.6.tgz",
"integrity": "sha512-KPztr4/tn7qDGZfqlSPQoF2VgJcKxnDNhmfR3VgZ6Fy1bO8T9Fc1stUiTXtqz0yG24VpD00pZP5f8EOFknjNuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.0.5",
"@vitest/spy": "3.0.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -6959,9 +6985,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz",
"integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.6.tgz",
"integrity": "sha512-Zyctv3dbNL+67qtHfRnUE/k8qxduOamRfAL1BurEIQSyOEFffoMvx2pnDSSbKAAVxY0Ej2J/GH2dQKI0W2JyVg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6972,14 +6998,14 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz",
"integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.6.tgz",
"integrity": "sha512-JopP4m/jGoaG1+CBqubV/5VMbi7L+NQCJTu1J1Pf6YaUbk7bZtaq5CX7p+8sY64Sjn1UQ1XJparHfcvTTdu9cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.0.5",
"pathe": "^2.0.2"
"@vitest/utils": "3.0.6",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -6993,15 +7019,15 @@
"license": "MIT"
},
"node_modules/@vitest/snapshot": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz",
"integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.6.tgz",
"integrity": "sha512-qKSmxNQwT60kNwwJHMVwavvZsMGXWmngD023OHSgn873pV0lylK7dwBTfYP7e4URy5NiBCHHiQGA9DHkYkqRqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.5",
"@vitest/pretty-format": "3.0.6",
"magic-string": "^0.30.17",
"pathe": "^2.0.2"
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -7015,9 +7041,9 @@
"license": "MIT"
},
"node_modules/@vitest/spy": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz",
"integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.6.tgz",
"integrity": "sha512-HfOGx/bXtjy24fDlTOpgiAEJbRfFxoX3zIGagCqACkFKKZ/TTOE6gYMKXlqecvxEndKFuNHcHqP081ggZ2yM0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7028,14 +7054,14 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz",
"integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.6.tgz",
"integrity": "sha512-18ktZpf4GQFTbf9jK543uspU03Q2qya7ZGya5yiZ0Gx0nnnalBvd5ZBislbl2EhLjM8A8rt4OilqKG7QwcGkvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.5",
"loupe": "^3.1.2",
"@vitest/pretty-format": "3.0.6",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
"funding": {
@@ -8746,9 +8772,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.101",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.101.tgz",
"integrity": "sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA==",
"version": "1.5.102",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz",
"integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -10093,9 +10119,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
@@ -10198,12 +10224,12 @@
}
},
"node_modules/framer-motion": {
"version": "12.4.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.3.tgz",
"integrity": "sha512-rsMeO7w3dKyNG09o3cGwSH49iHU+VgDmfSSfsX+wfkO3zDA6WWkh4sUsMXd155YROjZP+7FTIhDrBYfgZeHjKQ==",
"version": "12.4.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.4.tgz",
"integrity": "sha512-JWkVwbJBgVkeZHNcnk8ififgwTF+5de9wbJnTLI+g9YqaGo75Xd5uRVDm9FR8chqRDOKcXv/71f40CGescYVmg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.0.0",
"motion-dom": "^12.4.4",
"motion-utils": "^12.0.0",
"tslib": "^2.4.0"
},
@@ -11752,9 +11778,9 @@
}
},
"node_modules/jose": {
"version": "5.9.6",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
"integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==",
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -12324,7 +12350,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -13503,9 +13528,9 @@
}
},
"node_modules/motion-dom": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.0.0.tgz",
"integrity": "sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==",
"version": "12.4.4",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.4.tgz",
"integrity": "sha512-D8Kjp8oqUNqxoAVmIlOH+YCMov/4koBAmG4OJs0VWfh18xkQEIsx9+S7yrXyx0XaMBEPtre6e9LiSW2Zs7vIhA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.0.0"
@@ -13527,9 +13552,9 @@
}
},
"node_modules/mrmime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"license": "MIT",
"engines": {
"node": ">=10"
@@ -14534,9 +14559,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.219.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.219.0.tgz",
"integrity": "sha512-RnjtcjI4UYTBsjfF4Fs1lICWmGjiqMU9H0fN2ab1BEcDOFL/2m9Fx/1viCxvMiQR8cmgWWpkipJXD0gY7czDOA==",
"version": "1.219.3",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.219.3.tgz",
"integrity": "sha512-oKN4no9RRAptZ86R/MvMjsxQnFAe97rwU2emmTzf/q9ng+7V4nU+APM0ItzrESFtRYx1X8kKtxDUlkujNhfMvw==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
@@ -14552,9 +14577,9 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.26.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.0.tgz",
"integrity": "sha512-6ugi/Mb7lyV5RA6KlnijFyDLMU253i7L0RRiObIzDoqj59KT9iTeNJbA/YGw6M7jP4vxaab0DOA8DgodTOA6EQ==",
"version": "10.26.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.2.tgz",
"integrity": "sha512-0gNmv4qpS9HaN3+40CLBAnKe0ZfyE4ZWo5xKlC1rVrr0ckkEvJvAQqKaHANdFKsGstoxrY4AItZ7kZSGVoVjgg==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -14679,7 +14704,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -14691,7 +14715,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/property-information": {
@@ -14883,9 +14906,9 @@
}
},
"node_modules/react-i18next": {
"version": "15.4.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz",
"integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==",
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz",
"integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
@@ -15999,9 +16022,9 @@
}
},
"node_modules/sirv": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz",
"integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
"integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
@@ -16013,9 +16036,9 @@
}
},
"node_modules/sirv-cli": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.0.tgz",
"integrity": "sha512-p88yHl8DmTOUJroRiW2o9ezJc/YRLxphBydX2NGQc3naKBA09B3EM4Q/yaN8FYF0e50fRSZP7dyatr72b1u5Jw==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz",
"integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==",
"license": "MIT",
"dependencies": {
"console-clear": "^1.1.0",
@@ -16572,6 +16595,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "17.6.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-17.6.0.tgz",
"integrity": "sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/style-to-object": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz",
@@ -17692,31 +17729,31 @@
}
},
"node_modules/vitest": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz",
"integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.6.tgz",
"integrity": "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "3.0.5",
"@vitest/mocker": "3.0.5",
"@vitest/pretty-format": "^3.0.5",
"@vitest/runner": "3.0.5",
"@vitest/snapshot": "3.0.5",
"@vitest/spy": "3.0.5",
"@vitest/utils": "3.0.5",
"chai": "^5.1.2",
"@vitest/expect": "3.0.6",
"@vitest/mocker": "3.0.6",
"@vitest/pretty-format": "^3.0.6",
"@vitest/runner": "3.0.6",
"@vitest/snapshot": "3.0.6",
"@vitest/spy": "3.0.6",
"@vitest/utils": "3.0.6",
"chai": "^5.2.0",
"debug": "^4.4.0",
"expect-type": "^1.1.0",
"magic-string": "^0.30.17",
"pathe": "^2.0.2",
"pathe": "^2.0.3",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.0.5",
"vite-node": "3.0.6",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -17732,8 +17769,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.0.5",
"@vitest/ui": "3.0.5",
"@vitest/browser": "3.0.6",
"@vitest/ui": "3.0.6",
"happy-dom": "*",
"jsdom": "*"
},
@@ -17769,16 +17806,16 @@
"license": "MIT"
},
"node_modules/vitest/node_modules/vite-node": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz",
"integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.6.tgz",
"integrity": "sha512-s51RzrTkXKJrhNbUzQRsarjmAae7VmMPAsRT7lppVpIg6mK3zGthP9Hgz0YQQKuNcF+Ii7DfYk3Fxz40jRmePw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.0",
"es-module-lexer": "^1.6.0",
"pathe": "^2.0.2",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0"
},
"bin": {
+12 -8
View File
@@ -13,26 +13,28 @@
"@react-router/serve": "^7.1.5",
"@react-types/shared": "^3.27.0",
"@reduxjs/toolkit": "^2.5.1",
"@tanstack/react-query": "^5.66.0",
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"@tanstack/react-query": "^5.66.7",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.4.2",
"framer-motion": "^12.4.4",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.3",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.22",
"jose": "^5.9.4",
"jose": "^5.10.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.217.6",
"posthog-js": "^1.219.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.4.0",
"react-i18next": "^15.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
@@ -40,7 +42,7 @@
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.0",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.0.1",
"vite": "^6.1.0",
@@ -49,7 +51,8 @@
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev",
"dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev",
"build": "npm run make-i18n && npm run typecheck && react-router build",
"start": "npx sirv-cli build/ --single",
"test": "vitest run",
@@ -93,7 +96,7 @@
"@types/ws": "^8.5.14",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/coverage-v8": "^3.0.6",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -111,6 +114,7 @@
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.1",
"stripe": "^17.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite-plugin-svgr": "^4.2.0",
+17
View File
@@ -274,6 +274,23 @@ class OpenHands {
return data.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",
{
amount,
},
);
return data.redirect_url;
}
static async getBalance(): Promise<string> {
const { data } = await openHands.get<{ credits: string }>(
"/api/billing/credits",
);
return data.credits;
}
static async getGitHubUser(): Promise<GitHubUser> {
const response = await openHands.get<GitHubUser>("/api/github/user");
@@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
<ContextMenu
testId="account-settings-context-menu"
ref={ref}
className="absolute left-full -top-1 z-10"
className="absolute right-full md:left-full -top-1 z-10"
>
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
@@ -0,0 +1,92 @@
import React from "react";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { useBalance } from "#/hooks/query/use-balance";
import { cn } from "#/utils/utils";
import MoneyIcon from "#/icons/money.svg?react";
import { SettingsInput } from "../settings/settings-input";
import { BrandButton } from "../settings/brand-button";
import { HelpLink } from "../settings/help-link";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
export function PaymentForm() {
const { data: balance, isLoading } = useBalance();
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
const billingFormAction = async (formData: FormData) => {
const amount = formData.get("top-up-input")?.toString();
if (amount?.trim()) {
if (!amountIsValid(amount)) return;
const float = parseFloat(amount);
addBalance({ amount: Number(float.toFixed(2)) });
}
setButtonIsDisabled(true);
};
const handleTopUpInputChange = (value: string) => {
setButtonIsDisabled(!amountIsValid(value));
};
return (
<form
action={billingFormAction}
data-testid="billing-settings"
className="flex flex-col gap-6 px-11 py-9"
>
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
Manage Credits
</h2>
<div
className={cn(
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
"text-[28px] leading-8 -tracking-[0.02em] font-bold",
)}
>
<div className="flex items-center gap-2">
<MoneyIcon width={22} height={14} />
<span>Balance</span>
</div>
{!isLoading && (
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
)}
{isLoading && <LoadingSpinner size="small" />}
</div>
<div className="flex flex-col gap-3">
<SettingsInput
testId="top-up-input"
name="top-up-input"
onChange={handleTopUpInputChange}
type="text"
label="Top-up amount"
placeholder="Specify an amount to top up your credits"
className="w-[680px]"
/>
<div className="flex items-center w-[680px] gap-2">
<BrandButton
variant="primary"
type="submit"
isDisabled={isPending || buttonIsDisabled}
>
Add credit
</BrandButton>
{isPending && <LoadingSpinner size="small" />}
</div>
</div>
<HelpLink
testId="payment-methods-link"
href="https://stripe.com/"
text="Manage payment methods on"
linkText="Stripe"
/>
</form>
);
}
@@ -12,6 +12,7 @@ interface SettingsInputProps {
isDisabled?: boolean;
startContent?: React.ReactNode;
className?: string;
onChange?: (value: string) => void;
}
export function SettingsInput({
@@ -25,6 +26,7 @@ export function SettingsInput({
isDisabled,
startContent,
className,
onChange,
}: SettingsInputProps) {
return (
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
@@ -35,13 +37,14 @@ export function SettingsInput({
</div>
<input
data-testid={testId}
onChange={(e) => onChange?.(e.target.value)}
name={name}
disabled={isDisabled}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
className={cn(
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-[#B7BDC2]",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
@@ -3,7 +3,7 @@ import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import toast from "react-hot-toast";
import { NavLink } from "react-router";
import { NavLink, useLocation } from "react-router";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
@@ -22,8 +22,10 @@ import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { cn } from "#/utils/utils";
export function Sidebar() {
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitHubUser();
@@ -42,20 +44,27 @@ export function Sidebar() {
React.useState(false);
React.useEffect(() => {
// We don't show toast errors for settings in the global error handler
// because we have a special case for 404 errors
if (
if (location.pathname === "/settings") {
setSettingsModalIsOpen(false);
} else if (
!isFetchingSettings &&
settingsIsError &&
settingsError?.status !== 404
) {
// We don't show toast errors for settings in the global error handler
// because we have a special case for 404 errors
toast.error(
"Something went wrong while fetching settings. Please reload the page.",
);
} else if (settingsError?.status === 404) {
setSettingsModalIsOpen(true);
}
}, [settingsError?.status, settingsError, isFetchingSettings]);
}, [
settingsError?.status,
settingsError,
isFetchingSettings,
location.pathname,
]);
const handleEndSession = () => {
dispatch(setCurrentAgentState(AgentState.LOADING));
@@ -71,8 +80,8 @@ export function Sidebar() {
return (
<>
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
<nav className="flex flex-row md:flex-col items-center justify-between h-full">
<div className="flex flex-col items-center gap-[26px]">
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
<div className="flex flex-row md:flex-col items-center gap-[26px]">
<div className="flex items-center justify-center">
<AllHandsLogoButton onClick={handleEndSession} />
</div>
@@ -83,16 +92,21 @@ export function Sidebar() {
ariaLabel="Conversations"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
>
<FaListUl size={22} />
<FaListUl
size={22}
className={cn(
conversationPanelIsOpen ? "text-white" : "text-[#9099AC]",
)}
/>
</TooltipButton>
<DocsButton />
</div>
<div className="flex flex-col items-center gap-[26px] mb-4">
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<NavLink
to="/settings"
className={({ isActive }) =>
isActive ? "text-white" : "text-[#9099AC]"
`${isActive ? "text-white" : "text-[#9099AC]"} mt-0.5 md:mt-0`
}
>
<SettingsButton />
@@ -23,7 +23,7 @@ export function ActionButton({
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
type="button"
>
<span className="relative z-10 group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
{children}
</span>
<span className="absolute -inset-[5px] border-2 border-red-400/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-in-out" />
@@ -75,7 +75,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
const isLLMKeySet = settings.LLM_API_KEY !== "**********";
const isLLMKeySet = settings.LLM_API_KEY === "**********";
return (
<div>
@@ -97,7 +97,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
label="API Key"
type="password"
className="w-[680px]"
startContent={<KeyStatusIcon isSet={isLLMKeySet} />}
startContent={isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />}
/>
<HelpLink
@@ -18,7 +18,7 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop onClose={onClose}>
<ModalBackdrop>
<div
data-testid="ai-config-modal"
className="bg-root-primary min-w-[384px] p-6 rounded-xl flex flex-col gap-2"
+1 -1
View File
@@ -46,7 +46,7 @@ async function prepareApp() {
}
}
const queryClient = new QueryClient(queryClientConfig);
export const queryClient = new QueryClient(queryClientConfig);
prepareApp().then(() =>
startTransition(() => {
@@ -0,0 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useCreateStripeCheckoutSession = () =>
useMutation({
mutationFn: async (variables: { amount: number }) => {
const redirectUrl = await OpenHands.createCheckoutSession(
variables.amount,
);
window.location.href = redirectUrl;
},
});
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
export const useBalance = () => {
const { data: config } = useConfig();
return useQuery({
queryKey: ["user", "balance"],
queryFn: OpenHands.getBalance,
enabled: config?.APP_MODE === "saas",
});
};
+3
View File
@@ -0,0 +1,3 @@
<svg width="22" height="14" viewBox="0 0 22 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 6C4.80222 6 4.60888 6.05865 4.44443 6.16853C4.27998 6.27841 4.15181 6.43459 4.07612 6.61732C4.00043 6.80004 3.98063 7.00111 4.01921 7.19509C4.0578 7.38907 4.15304 7.56725 4.29289 7.70711C4.43275 7.84696 4.61093 7.9422 4.80491 7.98079C4.99889 8.01937 5.19996 7.99957 5.38268 7.92388C5.56541 7.84819 5.72159 7.72002 5.83147 7.55557C5.94135 7.39112 6 7.19778 6 7C6 6.73478 5.89464 6.48043 5.70711 6.29289C5.51957 6.10536 5.26522 6 5 6ZM17 6C16.8022 6 16.6089 6.05865 16.4444 6.16853C16.28 6.27841 16.1518 6.43459 16.0761 6.61732C16.0004 6.80004 15.9806 7.00111 16.0192 7.19509C16.0578 7.38907 16.153 7.56725 16.2929 7.70711C16.4327 7.84696 16.6109 7.9422 16.8049 7.98079C16.9989 8.01937 17.2 7.99957 17.3827 7.92388C17.5654 7.84819 17.7216 7.72002 17.8315 7.55557C17.9414 7.39112 18 7.19778 18 7C18 6.73478 17.8946 6.48043 17.7071 6.29289C17.5196 6.10536 17.2652 6 17 6ZM19 0H3C2.20435 0 1.44129 0.316071 0.87868 0.87868C0.31607 1.44129 0 2.20435 0 3V11C0 11.7956 0.31607 12.5587 0.87868 13.1213C1.44129 13.6839 2.20435 14 3 14H19C19.7956 14 20.5587 13.6839 21.1213 13.1213C21.6839 12.5587 22 11.7956 22 11V3C22 2.20435 21.6839 1.44129 21.1213 0.87868C20.5587 0.316071 19.7956 0 19 0ZM20 11C20 11.2652 19.8946 11.5196 19.7071 11.7071C19.5196 11.8946 19.2652 12 19 12H3C2.73478 12 2.48043 11.8946 2.29289 11.7071C2.10536 11.5196 2 11.2652 2 11V3C2 2.73478 2.10536 2.48043 2.29289 2.29289C2.48043 2.10536 2.73478 2 3 2H19C19.2652 2 19.5196 2.10536 19.7071 2.29289C19.8946 2.48043 20 2.73478 20 3V11ZM11 4C10.4067 4 9.82664 4.17595 9.33329 4.50559C8.83994 4.83524 8.45542 5.30377 8.22836 5.85195C8.0013 6.40013 7.94189 7.00333 8.05764 7.58527C8.1734 8.16721 8.45912 8.70176 8.87868 9.12132C9.29824 9.54088 9.83279 9.8266 10.4147 9.94236C10.9967 10.0581 11.5999 9.9987 12.1481 9.77164C12.6962 9.54458 13.1648 9.16006 13.4944 8.66671C13.8241 8.17336 14 7.59334 14 7C14 6.20435 13.6839 5.44129 13.1213 4.87868C12.5587 4.31607 11.7956 4 11 4ZM11 8C10.8022 8 10.6089 7.94135 10.4444 7.83147C10.28 7.72159 10.1518 7.56541 10.0761 7.38268C10.0004 7.19996 9.98063 6.99889 10.0192 6.80491C10.0578 6.61093 10.153 6.43275 10.2929 6.29289C10.4327 6.15304 10.6109 6.0578 10.8049 6.01921C10.9989 5.98063 11.2 6.00043 11.3827 6.07612C11.5654 6.15181 11.7216 6.27998 11.8315 6.44443C11.9414 6.60888 12 6.80222 12 7C12 7.26522 11.8946 7.51957 11.7071 7.70711C11.5196 7.89464 11.2652 8 11 8Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+36
View File
@@ -0,0 +1,36 @@
import { delay, http, HttpResponse } from "msw";
import Stripe from "stripe";
const TEST_STRIPE_SECRET_KEY = "";
const PRICE_ID = "";
export const STRIPE_BILLING_HANDLERS = [
http.get("/api/billing/credits", async () => {
await delay();
return HttpResponse.json({ credits: "100" });
}),
http.post("/api/billing/create-checkout-session", async ({ request }) => {
await delay();
const body = await request.json();
if (body && typeof body === "object" && body.amount) {
const stripe = new Stripe(TEST_STRIPE_SECRET_KEY);
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: PRICE_ID,
quantity: body.amount,
},
],
mode: "payment",
success_url: "http://localhost:3001/settings/billing/?checkout=success",
cancel_url: "http://localhost:3001/settings/billing/?checkout=cancel",
});
if (session.url) return HttpResponse.json({ redirect_url: session.url });
}
return HttpResponse.json({ message: "Invalid request" }, { status: 400 });
}),
];
+4 -1
View File
@@ -5,6 +5,7 @@ import {
ResultSet,
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
@@ -144,6 +145,7 @@ const openHandsHandlers = [
];
export const handlers = [
...STRIPE_BILLING_HANDLERS,
...openHandsHandlers,
http.get("/api/github/repositories", () =>
HttpResponse.json([
@@ -170,8 +172,9 @@ export const handlers = [
HttpResponse.json(null, { status: 200 }),
),
http.get("/api/options/config", () => {
const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
const config: GetConfigResponse = {
APP_MODE: "oss",
APP_MODE: mockSaas ? "saas" : "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
};
+4 -1
View File
@@ -8,7 +8,10 @@ import {
export default [
layout("routes/_oh/route.tsx", [
index("routes/_oh._index/route.tsx"),
route("settings", "routes/settings.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
route("billing", "routes/billing.tsx"),
]),
route("conversations/:conversationId", "routes/_oh.app/route.tsx", [
index("routes/_oh.app._index/route.tsx"),
route("browser", "routes/_oh.app.browser.tsx"),
+447
View File
@@ -0,0 +1,447 @@
import React from "react";
import { Link } from "react-router";
import { BrandButton } from "#/components/features/settings/brand-button";
import { HelpLink } from "#/components/features/settings/help-link";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { useAppLogout } from "#/hooks/use-app-logout";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { isCustomModel } from "#/utils/is-custom-model";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
const REMOTE_RUNTIME_OPTIONS = [
{ key: 1, label: "1x (2 core, 8G)" },
{ key: 2, label: "2x (4 core, 16G)" },
];
function AccountSettings() {
const {
data: settings,
isFetching: isFetchingSettings,
isFetched,
isSuccess: isSuccessfulSettings,
} = useSettings();
const { data: config } = useConfig();
const {
data: resources,
isFetching: isFetchingResources,
isSuccess: isSuccessfulResources,
} = useAIConfigOptions();
const { mutate: saveSettings } = useSaveSettings();
const { handleLogout } = useAppLogout();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
const determineWhetherToToggleAdvancedSettings = () => {
if (isSuccess) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet(settings)
);
}
return false;
};
const isSaas = config?.APP_MODE === "saas";
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
const isLLMKeySet = settings?.LLM_API_KEY === "**********";
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
const [llmConfigMode, setLlmConfigMode] = React.useState<
"basic" | "advanced"
>(isAdvancedSettingsSet ? "advanced" : "basic");
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
React.useState(!!settings?.SECURITY_ANALYZER);
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
const onSubmit = async (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const llmProvider = formData.get("llm-provider-input")?.toString();
const llmModel = formData.get("llm-model-input")?.toString();
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
const rawRemoteRuntimeResourceFactor = formData
.get("runtime-settings-input")
?.toString();
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
saveSettings(
{
github_token:
formData.get("github-token-input")?.toString() || undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
LLM_MODEL: customLlmModel || fullLlmModel,
LLM_BASE_URL: formData.get("base-url-input")?.toString() || "",
LLM_API_KEY:
formData.get("llm-api-key-input")?.toString() ||
(isLLMKeySet
? undefined // don't update if it's already set
: ""), // reset if it's first time save to avoid 500 error
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor ||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
},
{
onSuccess: () => {
handleCaptureConsent(userConsentsToAnalytics);
displaySuccessToast("Settings saved");
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage);
},
},
);
};
const handleReset = () => {
saveSettings(
{
...DEFAULT_SETTINGS,
LLM_API_KEY: "", // reset LLM API key
},
{
onSuccess: () => {
displaySuccessToast("Settings reset");
setResetSettingsModalIsOpen(false);
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
},
);
};
React.useEffect(() => {
// If settings is still loading by the time the state is set, it will always
// default to basic settings. This is a workaround to ensure the correct
// settings are displayed.
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
}, [isAdvancedSettingsSet]);
if (isFetched && !settings) {
return <div>Failed to fetch settings. Please try reloading.</div>;
}
const onToggleAdvancedMode = (isToggled: boolean) => {
setLlmConfigMode(isToggled ? "advanced" : "basic");
if (!isToggled) {
// reset advanced state
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
}
};
if (isFetching || !settings) {
return (
<div className="flex grow p-4">
<LoadingSpinner size="large" />
</div>
);
}
return (
<>
<form
ref={formRef}
action={onSubmit}
className="flex flex-col grow overflow-auto"
>
<div className="flex flex-col gap-12 px-11 py-9">
<section className="flex flex-col gap-6">
<div className="flex items-center gap-7">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
LLM Settings
</h2>
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={isAdvancedSettingsSet}
onToggle={onToggleAdvancedMode}
>
Advanced
</SettingsSwitch>
</div>
{llmConfigMode === "basic" && (
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label="Custom Model"
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
)}
{llmConfigMode === "advanced" && (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label="Base URL"
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
)}
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label="API Key"
type="password"
className="w-[680px]"
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
placeholder={isLLMKeySet ? "**********" : ""}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text="Don't know your API key?"
linkText="Click here for instructions"
href="https://docs.all-hands.dev/modules/usage/llms"
/>
{llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label="Agent"
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
)}
{isSaas && llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label="Runtime Settings"
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled
isClearable={false}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
Enable confirmation mode
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
<div>
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label="Security Analyzer"
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
/>
</div>
)}
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
GitHub Settings
</h2>
{isSaas && hasAppSlug && (
<Link
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
>
<BrandButton type="button" variant="secondary">
Configure GitHub Repositories
</BrandButton>
</Link>
)}
{!isSaas && (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label="GitHub Token"
type="password"
className="w-[680px]"
startContent={
isGitHubTokenSet && (
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "**********" : ""}
/>
<HelpLink
testId="github-token-help-anchor"
text="Get your token"
linkText="here"
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
/>
</>
)}
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!isGitHubTokenSet}
>
Disconnect from GitHub
</BrandButton>
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
Additional Settings
</h2>
<SettingsDropdownInput
testId="language-input"
name="language-input"
label="Language"
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
isClearable={false}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!isAnalyticsEnabled}
>
Enable analytics
</SettingsSwitch>
</section>
</div>
</form>
<footer className="flex gap-6 p-6 justify-end border-t border-t-[#454545]">
<BrandButton
type="button"
variant="secondary"
onClick={() => setResetSettingsModalIsOpen(true)}
>
Reset to defaults
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
Save Changes
</BrandButton>
</footer>
{resetSettingsModalIsOpen && (
<ModalBackdrop>
<div
data-testid="reset-modal"
className="bg-root-primary p-4 rounded-xl flex flex-col gap-4"
>
<p>Are you sure you want to reset all settings?</p>
<div className="w-full flex gap-2">
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={() => {
handleReset();
}}
>
Reset
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={() => {
setResetSettingsModalIsOpen(false);
}}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
)}
</>
);
}
export default AccountSettings;
+39
View File
@@ -0,0 +1,39 @@
import { redirect, useSearchParams } from "react-router";
import React from "react";
import { PaymentForm } from "#/components/features/payment/payment-form";
import { GetConfigResponse } from "#/api/open-hands.types";
import { queryClient } from "#/entry.client";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
export const clientLoader = async () => {
const config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (config?.APP_MODE !== "saas" || !BILLING_SETTINGS()) {
return redirect("/settings");
}
return null;
};
function BillingSettingsScreen() {
const [searchParams, setSearchParams] = useSearchParams();
const checkoutStatus = searchParams.get("checkout");
React.useEffect(() => {
if (checkoutStatus === "success") {
displaySuccessToast("Payment successful");
} else if (checkoutStatus === "cancel") {
displayErrorToast("Payment cancelled");
}
setSearchParams({});
}, [checkoutStatus]);
return <PaymentForm />;
}
export default BillingSettingsScreen;
+37 -435
View File
@@ -1,450 +1,52 @@
import React from "react";
import toast from "react-hot-toast";
import { Link } from "react-router";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { HelpLink } from "#/components/features/settings/help-link";
import { AvailableLanguages } from "#/i18n";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useSettings } from "#/hooks/query/use-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { useAppLogout } from "#/hooks/use-app-logout";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { NavLink, Outlet } from "react-router";
import SettingsIcon from "#/icons/settings.svg?react";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { isCustomModel } from "#/utils/is-custom-model";
const REMOTE_RUNTIME_OPTIONS = [
{ key: 1, label: "1x (2 core, 8G)" },
{ key: 2, label: "2x (4 core, 16G)" },
];
const displayErrorToast = (error: string) => {
toast.error(error, {
position: "top-right",
style: {
background: "#454545",
border: "1px solid #717888",
color: "#fff",
borderRadius: "4px",
},
});
};
const displaySuccessToast = (message: string) => {
toast.success(message, {
position: "top-right",
style: {
background: "#454545",
border: "1px solid #717888",
color: "#fff",
borderRadius: "4px",
},
});
};
import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
function SettingsScreen() {
const {
data: settings,
isFetching: isFetchingSettings,
isFetched,
isSuccess: isSuccessfulSettings,
} = useSettings();
const { data: config } = useConfig();
const {
data: resources,
isFetching: isFetchingResources,
isSuccess: isSuccessfulResources,
} = useAIConfigOptions();
const { mutate: saveSettings } = useSaveSettings();
const { handleLogout } = useAppLogout();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
const determineWhetherToToggleAdvancedSettings = () => {
if (isSuccess) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet(settings)
);
}
return false;
};
const isSaas = config?.APP_MODE === "saas";
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
const isLLMKeySet = settings?.LLM_API_KEY === "**********";
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
const [llmConfigMode, setLlmConfigMode] = React.useState<
"basic" | "advanced"
>(isAdvancedSettingsSet ? "advanced" : "basic");
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
React.useState(!!settings?.SECURITY_ANALYZER);
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
React.useState(false);
const formAction = async (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const llmProvider = formData.get("llm-provider-input")?.toString();
const llmModel = formData.get("llm-model-input")?.toString();
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
const rawRemoteRuntimeResourceFactor = formData
.get("runtime-settings-input")
?.toString();
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
saveSettings(
{
github_token:
formData.get("github-token-input")?.toString() || undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
LLM_MODEL: customLlmModel || fullLlmModel,
LLM_BASE_URL: formData.get("base-url-input")?.toString() || "",
LLM_API_KEY: formData.get("llm-api-key-input")?.toString() || undefined,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor ||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
},
{
onSuccess: () => {
handleCaptureConsent(userConsentsToAnalytics);
displaySuccessToast("Settings saved");
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage);
},
},
);
};
const handleReset = () => {
saveSettings(
{
...DEFAULT_SETTINGS,
LLM_API_KEY: "", // reset LLM API key
},
{
onSuccess: () => {
displaySuccessToast("Settings reset");
setResetSettingsModalIsOpen(false);
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
},
);
};
React.useEffect(() => {
// If settings is still loading by the time the state is set, it will always
// default to basic settings. This is a workaround to ensure the correct
// settings are displayed.
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
}, [isAdvancedSettingsSet]);
if (isFetched && !settings) {
return <div>Failed to fetch settings. Please try reloading.</div>;
}
const onToggleAdvancedMode = (isToggled: boolean) => {
setLlmConfigMode(isToggled ? "advanced" : "basic");
if (!isToggled) {
// reset advanced state
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
}
};
return (
<main
data-testid="settings-screen"
className="bg-[#24272E] border border-[#454545] h-full rounded-xl"
className="bg-[#24272E] border border-[#454545] h-full rounded-xl flex flex-col"
>
<form action={formAction} className="flex flex-col h-full">
<header className="px-3 py-1.5 border-b border-b-[#454545] flex items-center gap-2">
<SettingsIcon width={16} height={16} />
<h1 className="text-sm leading-6">Settings</h1>
</header>
<header className="px-3 py-1.5 border-b border-b-[#454545] flex items-center gap-2">
<SettingsIcon width={16} height={16} />
<h1 className="text-sm leading-6">Settings</h1>
</header>
{isFetching && (
<div className="flex grow p-4">
<LoadingSpinner size="large" />
</div>
)}
{!isFetching && settings && (
<div className="flex flex-col gap-12 grow overflow-y-auto px-11 py-9">
<section className="flex flex-col gap-6">
<div className="flex items-center gap-7">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
LLM Settings
</h2>
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={isAdvancedSettingsSet}
onToggle={onToggleAdvancedMode}
>
Advanced
</SettingsSwitch>
</div>
{llmConfigMode === "basic" && (
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label="Custom Model"
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
)}
{llmConfigMode === "advanced" && (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label="Base URL"
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
)}
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label="API Key"
type="password"
className="w-[680px]"
startContent={<KeyStatusIcon isSet={isLLMKeySet} />}
placeholder={isLLMKeySet ? "**********" : ""}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text="Don't know your API key?"
linkText="Click here for instructions"
href="https://docs.all-hands.dev/modules/usage/llms"
/>
{llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label="Agent"
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
)}
{isSaas && llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label="Runtime Settings"
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isClearable={false}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
Enable confirmation mode
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
<div>
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label="Security Analyzer"
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
/>
</div>
)}
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
GitHub Settings
</h2>
{isSaas && hasAppSlug && (
<Link
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
>
<BrandButton type="button" variant="secondary">
Configure GitHub Repositories
</BrandButton>
</Link>
)}
{!isSaas && (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label="GitHub Token"
type="password"
className="w-[680px]"
startContent={<KeyStatusIcon isSet={!!isGitHubTokenSet} />}
/>
<HelpLink
testId="github-token-help-anchor"
text="Get your token"
linkText="here"
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
/>
</>
)}
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!isGitHubTokenSet}
>
Disconnect from GitHub
</BrandButton>
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
Additional Settings
</h2>
<SettingsDropdownInput
testId="language-input"
name="language-input"
label="Language"
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
isClearable={false}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!isAnalyticsEnabled}
>
Enable analytics
</SettingsSwitch>
</section>
</div>
)}
<footer className="flex gap-6 p-6 justify-end border-t border-t-[#454545]">
<BrandButton
type="button"
variant="secondary"
onClick={() => setResetSettingsModalIsOpen(true)}
>
Reset to defaults
</BrandButton>
<BrandButton type="submit" variant="primary">
Save Changes
</BrandButton>
</footer>
</form>
{resetSettingsModalIsOpen && (
<ModalBackdrop>
<div
data-testid="reset-modal"
className="bg-root-primary p-4 rounded-xl flex flex-col gap-4"
>
<p>Are you sure you want to reset all settings?</p>
<div className="w-full flex gap-2">
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={() => {
handleReset();
}}
>
Reset
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={() => {
setResetSettingsModalIsOpen(false);
}}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
{isSaas && BILLING_SETTINGS() && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-[#454545]"
>
{[
{ to: "/settings", text: "Account" },
{ to: "/settings/billing", text: "Credits" },
].map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-[#C9B974]",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
)}
<div className="flex flex-col grow overflow-auto">
<Outlet />
</div>
</main>
);
}
+10
View File
@@ -0,0 +1,10 @@
const MINIMUM_AMOUNT = 25;
export const amountIsValid = (amount: string) => {
const float = parseFloat(amount);
if (Number.isNaN(float)) return false;
if (float < 0) return false;
if (float < MINIMUM_AMOUNT) return false;
return true;
};
@@ -0,0 +1,25 @@
import toast from "react-hot-toast";
export const displayErrorToast = (error: string) => {
toast.error(error, {
position: "top-right",
style: {
background: "#454545",
border: "1px solid #717888",
color: "#fff",
borderRadius: "4px",
},
});
};
export const displaySuccessToast = (message: string) => {
toast.success(message, {
position: "top-right",
style: {
background: "#454545",
border: "1px solid #717888",
color: "#fff",
borderRadius: "4px",
},
});
};
+1
View File
@@ -13,3 +13,4 @@ function loadFeatureFlag(
}
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
+13 -65
View File
@@ -1,69 +1,17 @@
import test, { expect, Page } from "@playwright/test";
import test, { expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/");
test("do not navigate to /settings/billing if not SaaS mode", async ({
page,
}) => {
await page.goto("/settings/billing");
await expect(page.getByTestId("settings-screen")).toBeVisible();
expect(page.url()).toBe("http://localhost:3001/settings");
});
const selectGpt4o = async (page: Page) => {
const aiConfigModal = page.getByTestId("ai-config-modal");
await expect(aiConfigModal).toBeVisible();
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
await providerSelectElement.click();
const openAiOption = page.getByTestId("provider-item-openai");
await openAiOption.click();
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
await modelSelectElement.click();
const gpt4Option = page.getByText("gpt-4o", { exact: true });
await gpt4Option.click();
return {
aiConfigModal,
providerSelectElement,
modelSelectElement,
};
};
test("change ai config settings", async ({ page }) => {
const { aiConfigModal, modelSelectElement, providerSelectElement } =
await selectGpt4o(page);
const saveButton = aiConfigModal.getByText("Save");
await saveButton.click();
const settingsButton = page.getByTestId("settings-button");
await settingsButton.click();
await expect(providerSelectElement).toHaveValue("OpenAI");
await expect(modelSelectElement).toHaveValue("gpt-4o");
});
test("reset to default settings", async ({ page }) => {
const { aiConfigModal } = await selectGpt4o(page);
const saveButton = aiConfigModal.getByText("Save");
await saveButton.click();
const settingsButton = page.getByTestId("settings-button");
await settingsButton.click();
const resetButton = aiConfigModal.getByText(/reset to defaults/i);
await resetButton.click();
const endSessionModal = page.getByTestId("reset-defaults-modal");
expect(endSessionModal).toBeVisible();
const confirmButton = endSessionModal.getByText(/reset to defaults/i);
await confirmButton.click();
await settingsButton.click();
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
await expect(providerSelectElement).toHaveValue("Anthropic");
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
await expect(modelSelectElement).toHaveValue(/claude-3.5/i);
// FIXME: This test is failing because the config is not being set to SaaS mode
// since MSW is always returning APP_MODE as "oss"
test.skip("navigate to /settings/billing if SaaS mode", async ({ page }) => {
await page.goto("/settings/billing");
await expect(page.getByTestId("settings-screen")).toBeVisible();
expect(page.url()).toBe("http://localhost:3001/settings/billing");
});
+52 -340
View File
@@ -2,41 +2,21 @@ import json
import os
from collections import deque
from litellm import ModelResponse
import openhands
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import ImageContent, Message, TextContent
from openhands.core.schema import ActionType
from openhands.core.message import Message, TextContent
from openhands.core.message_utils import (
apply_prompt_caching,
events_to_messages,
)
from openhands.events.action import (
Action,
AgentDelegateAction,
AgentFinishAction,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
IPythonRunCellAction,
MessageAction,
)
from openhands.events.observation import (
AgentCondensationObservation,
AgentDelegateObservation,
BrowserOutputObservation,
CmdOutputObservation,
FileEditObservation,
FileReadObservation,
IPythonRunCellObservation,
UserRejectObservation,
)
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.observation import Observation
from openhands.events.serialization.event import truncate_content
from openhands.llm.llm import LLM
from openhands.memory.condenser import Condenser
from openhands.runtime.plugins import (
@@ -113,247 +93,6 @@ class CodeActAgent(Agent):
self.condenser = Condenser.from_config(self.config.condenser)
logger.debug(f'Using condenser: {self.condenser}')
def get_action_message(
self,
action: Action,
pending_tool_call_action_messages: dict[str, Message],
) -> list[Message]:
"""Converts an action into a message format that can be sent to the LLM.
This method handles different types of actions and formats them appropriately:
1. For tool-based actions (AgentDelegate, CmdRun, IPythonRunCell, FileEdit) and agent-sourced AgentFinish:
- In function calling mode: Stores the LLM's response in pending_tool_call_action_messages
- In non-function calling mode: Creates a message with the action string
2. For MessageActions: Creates a message with the text content and optional image content
Args:
action (Action): The action to convert. Can be one of:
- CmdRunAction: For executing bash commands
- IPythonRunCellAction: For running IPython code
- FileEditAction: For editing files
- FileReadAction: For reading files using openhands-aci commands
- BrowseInteractiveAction: For browsing the web
- AgentFinishAction: For ending the interaction
- MessageAction: For sending messages
pending_tool_call_action_messages (dict[str, Message]): Dictionary mapping response IDs
to their corresponding messages. Used in function calling mode to track tool calls
that are waiting for their results.
Returns:
list[Message]: A list containing the formatted message(s) for the action.
May be empty if the action is handled as a tool call in function calling mode.
Note:
In function calling mode, tool-based actions are stored in pending_tool_call_action_messages
rather than being returned immediately. They will be processed later when all corresponding
tool call results are available.
"""
# create a regular message from an event
if isinstance(
action,
(
AgentDelegateAction,
IPythonRunCellAction,
FileEditAction,
FileReadAction,
BrowseInteractiveAction,
BrowseURLAction,
),
) or (isinstance(action, CmdRunAction) and action.source == 'agent'):
tool_metadata = action.tool_call_metadata
assert tool_metadata is not None, (
'Tool call metadata should NOT be None when function calling is enabled. Action: '
+ str(action)
)
llm_response: ModelResponse = tool_metadata.model_response
assistant_msg = llm_response.choices[0].message
# Add the LLM message (assistant) that initiated the tool calls
# (overwrites any previous message with the same response_id)
logger.debug(
f'Tool calls type: {type(assistant_msg.tool_calls)}, value: {assistant_msg.tool_calls}'
)
pending_tool_call_action_messages[llm_response.id] = Message(
role=assistant_msg.role,
# tool call content SHOULD BE a string
content=[TextContent(text=assistant_msg.content or '')]
if assistant_msg.content is not None
else [],
tool_calls=assistant_msg.tool_calls,
)
return []
elif isinstance(action, AgentFinishAction):
role = 'user' if action.source == 'user' else 'assistant'
# when agent finishes, it has tool_metadata
# which has already been executed, and it doesn't have a response
# when the user finishes (/exit), we don't have tool_metadata
tool_metadata = action.tool_call_metadata
if tool_metadata is not None:
# take the response message from the tool call
assistant_msg = tool_metadata.model_response.choices[0].message
content = assistant_msg.content or ''
# save content if any, to thought
if action.thought:
if action.thought != content:
action.thought += '\n' + content
else:
action.thought = content
# remove the tool call metadata
action.tool_call_metadata = None
return [
Message(
role=role,
content=[TextContent(text=action.thought)],
)
]
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
if self.llm.vision_is_active() and action.image_urls:
content.append(ImageContent(image_urls=action.image_urls))
return [
Message(
role=role,
content=content,
)
]
elif isinstance(action, CmdRunAction) and action.source == 'user':
content = [
TextContent(text=f'User executed the command:\n{action.command}')
]
return [
Message(
role='user',
content=content,
)
]
return []
def get_observation_message(
self,
obs: Observation,
tool_call_id_to_message: dict[str, Message],
) -> list[Message]:
"""Converts an observation into a message format that can be sent to the LLM.
This method handles different types of observations and formats them appropriately:
- CmdOutputObservation: Formats command execution results with exit codes
- IPythonRunCellObservation: Formats IPython cell execution results, replacing base64 images
- FileEditObservation: Formats file editing results
- FileReadObservation: Formats file reading results from openhands-aci
- AgentDelegateObservation: Formats results from delegated agent tasks
- ErrorObservation: Formats error messages from failed actions
- UserRejectObservation: Formats user rejection messages
In function calling mode, observations with tool_call_metadata are stored in
tool_call_id_to_message for later processing instead of being returned immediately.
Args:
obs (Observation): The observation to convert
tool_call_id_to_message (dict[str, Message]): Dictionary mapping tool call IDs
to their corresponding messages (used in function calling mode)
Returns:
list[Message]: A list containing the formatted message(s) for the observation.
May be empty if the observation is handled as a tool response in function calling mode.
Raises:
ValueError: If the observation type is unknown
"""
message: Message
max_message_chars = self.llm.config.max_message_chars
if isinstance(obs, CmdOutputObservation):
# if it doesn't have tool call metadata, it was triggered by a user action
if obs.tool_call_metadata is None:
text = truncate_content(
f'\nObserved result of command executed by user:\n{obs.to_agent_observation()}',
max_message_chars,
)
else:
text = truncate_content(obs.to_agent_observation(), max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, IPythonRunCellObservation):
text = obs.content
# replace base64 images with a placeholder
splitted = text.split('\n')
for i, line in enumerate(splitted):
if '![image](data:image/png;base64,' in line:
splitted[i] = (
'![image](data:image/png;base64, ...) already displayed to user'
)
text = '\n'.join(splitted)
text = truncate_content(text, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, FileEditObservation):
text = truncate_content(str(obs), max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, FileReadObservation):
message = Message(
role='user', content=[TextContent(text=obs.content)]
) # Content is already truncated by openhands-aci
elif isinstance(obs, BrowserOutputObservation):
text = obs.get_agent_obs_text()
if (
obs.trigger_by_action == ActionType.BROWSE_INTERACTIVE
and obs.set_of_marks is not None
and len(obs.set_of_marks) > 0
and self.config.enable_som_visual_browsing
and self.llm.vision_is_active()
):
text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.)\n'
message = Message(
role='user',
content=[
TextContent(text=text),
ImageContent(image_urls=[obs.set_of_marks]),
],
)
else:
message = Message(
role='user',
content=[TextContent(text=text)],
)
elif isinstance(obs, AgentDelegateObservation):
text = truncate_content(
obs.outputs['content'] if 'content' in obs.outputs else '',
max_message_chars,
)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, ErrorObservation):
text = truncate_content(obs.content, max_message_chars)
text += '\n[Error occurred in processing last action]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, UserRejectObservation):
text = 'OBSERVATION:\n' + truncate_content(obs.content, max_message_chars)
text += '\n[Last action has been rejected by the user]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, AgentCondensationObservation):
text = truncate_content(obs.content, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
else:
# If an observation message is not returned, it will cause an error
# when the LLM tries to return the next message
raise ValueError(f'Unknown observation type: {type(obs)}')
# Update the message as tool response properly
if (tool_call_metadata := obs.tool_call_metadata) is not None:
tool_call_id_to_message[tool_call_metadata.tool_call_id] = Message(
role='tool',
content=message.content,
tool_call_id=tool_call_metadata.tool_call_id,
name=tool_call_metadata.function_name,
)
# No need to return the observation message
# because it will be added by get_action_message when all the corresponding
# tool calls in the SAME request are processed
return []
return [message]
def reset(self) -> None:
"""Resets the CodeAct Agent."""
super().reset()
@@ -429,7 +168,30 @@ class CodeActAgent(Agent):
if not self.prompt_manager:
raise Exception('Prompt Manager not instantiated.')
messages: list[Message] = [
messages: list[Message] = self._initial_messages()
# Condense the events from the state.
events = self.condenser.condensed_history(state)
messages += events_to_messages(
events,
max_message_chars=self.llm.config.max_message_chars,
vision_is_active=self.llm.vision_is_active(),
enable_som_visual_browsing=self.config.enable_som_visual_browsing,
)
messages = self._enhance_messages(messages)
if self.llm.is_caching_prompt_active():
apply_prompt_caching(messages)
return messages
def _initial_messages(self) -> list[Message]:
"""Creates the initial messages (including the system prompt) for the LLM conversation."""
assert self.prompt_manager, 'Prompt Manager not instantiated.'
return [
Message(
role='system',
content=[
@@ -441,84 +203,34 @@ class CodeActAgent(Agent):
)
]
pending_tool_call_action_messages: dict[str, Message] = {}
tool_call_id_to_message: dict[str, Message] = {}
def _enhance_messages(self, messages: list[Message]) -> list[Message]:
"""Enhances the user message with additional context based on keywords matched.
# Condense the events from the state.
events = self.condenser.condensed_history(state)
Args:
messages (list[Message]): The list of messages to enhance
Returns:
list[Message]: The enhanced list of messages
"""
assert self.prompt_manager, 'Prompt Manager not instantiated.'
results: list[Message] = []
is_first_message_handled = False
for event in events:
# create a regular message from an event
if isinstance(event, Action):
messages_to_add = self.get_action_message(
action=event,
pending_tool_call_action_messages=pending_tool_call_action_messages,
)
elif isinstance(event, Observation):
messages_to_add = self.get_observation_message(
obs=event,
tool_call_id_to_message=tool_call_id_to_message,
)
else:
raise ValueError(f'Unknown event type: {type(event)}')
# Check pending tool call action messages and see if they are complete
_response_ids_to_remove = []
for (
response_id,
pending_message,
) in pending_tool_call_action_messages.items():
assert pending_message.tool_calls is not None, (
'Tool calls should NOT be None when function calling is enabled & the message is considered pending tool call. '
f'Pending message: {pending_message}'
)
if all(
tool_call.id in tool_call_id_to_message
for tool_call in pending_message.tool_calls
):
# If complete:
# -- 1. Add the message that **initiated** the tool calls
messages_to_add.append(pending_message)
# -- 2. Add the tool calls **results***
for tool_call in pending_message.tool_calls:
messages_to_add.append(tool_call_id_to_message[tool_call.id])
tool_call_id_to_message.pop(tool_call.id)
_response_ids_to_remove.append(response_id)
# Cleanup the processed pending tool messages
for response_id in _response_ids_to_remove:
pending_tool_call_action_messages.pop(response_id)
for msg in messages:
if msg.role == 'user' and not is_first_message_handled:
is_first_message_handled = True
# compose the first user message with examples
self.prompt_manager.add_examples_to_initial_message(msg)
for msg in messages_to_add:
if msg:
if msg.role == 'user' and not is_first_message_handled:
is_first_message_handled = True
# compose the first user message with examples
self.prompt_manager.add_examples_to_initial_message(msg)
# and/or repo/runtime info
if self.config.enable_prompt_extensions:
self.prompt_manager.add_info_to_initial_message(msg)
# and/or repo/runtime info
if self.config.enable_prompt_extensions:
self.prompt_manager.add_info_to_initial_message(msg)
# enhance the user message with additional context based on keywords matched
if msg.role == 'user':
self.prompt_manager.enhance_message(msg)
# enhance the user message with additional context based on keywords matched
if msg.role == 'user':
self.prompt_manager.enhance_message(msg)
results.append(msg)
messages.append(msg)
if self.llm.is_caching_prompt_active():
# NOTE: this is only needed for anthropic
# following logic here:
# https://github.com/anthropics/anthropic-quickstarts/blob/8f734fd08c425c6ec91ddd613af04ff87d70c5a0/computer-use-demo/computer_use_demo/loop.py#L241-L262
breakpoints_remaining = 3 # remaining 1 for system/tool
for message in reversed(messages):
if message.role in ('user', 'tool'):
if breakpoints_remaining > 0:
message.content[
-1
].cache_prompt = True # Last item inside the message content
breakpoints_remaining -= 1
else:
break
return messages
return results
+3
View File
@@ -52,6 +52,9 @@ class SandboxConfig(BaseModel):
remote_runtime_init_timeout: int = Field(default=180)
remote_runtime_api_timeout: int = Field(default=10)
remote_runtime_enable_retries: bool = Field(default=False)
remote_runtime_class: str | None = Field(
default='sysbox'
) # can be "None" (default to gvisor) or "sysbox" (support docker inside runtime + more stable)
enable_auto_lint: bool = Field(
default=False # once enabled, OpenHands would lint files after editing
)
+367
View File
@@ -0,0 +1,367 @@
from litellm import ModelResponse
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import ImageContent, Message, TextContent
from openhands.core.schema import ActionType
from openhands.events.action import (
Action,
AgentDelegateAction,
AgentFinishAction,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
IPythonRunCellAction,
MessageAction,
)
from openhands.events.event import Event
from openhands.events.observation import (
AgentCondensationObservation,
AgentDelegateObservation,
BrowserOutputObservation,
CmdOutputObservation,
FileEditObservation,
FileReadObservation,
IPythonRunCellObservation,
UserRejectObservation,
)
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.observation import Observation
from openhands.events.serialization.event import truncate_content
def events_to_messages(
events: list[Event],
max_message_chars: int | None = None,
vision_is_active: bool = False,
enable_som_visual_browsing: bool = False,
) -> list[Message]:
"""Converts a list of events into a list of messages that can be sent to the LLM.
Ensures that tool call actions are processed correctly in function calling mode.
Args:
events: A list of events to convert. Each event can be an Action or Observation.
max_message_chars: The maximum number of characters in the content of an event included in the prompt to the LLM.
Larger observations are truncated.
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
enable_som_visual_browsing: Whether to enable visual browsing for the SOM model.
"""
messages = []
pending_tool_call_action_messages: dict[str, Message] = {}
tool_call_id_to_message: dict[str, Message] = {}
for event in events:
# create a regular message from an event
if isinstance(event, Action):
messages_to_add = get_action_message(
action=event,
pending_tool_call_action_messages=pending_tool_call_action_messages,
vision_is_active=vision_is_active,
)
elif isinstance(event, Observation):
messages_to_add = get_observation_message(
obs=event,
tool_call_id_to_message=tool_call_id_to_message,
max_message_chars=max_message_chars,
vision_is_active=vision_is_active,
enable_som_visual_browsing=enable_som_visual_browsing,
)
else:
raise ValueError(f'Unknown event type: {type(event)}')
# Check pending tool call action messages and see if they are complete
_response_ids_to_remove = []
for (
response_id,
pending_message,
) in pending_tool_call_action_messages.items():
assert pending_message.tool_calls is not None, (
'Tool calls should NOT be None when function calling is enabled & the message is considered pending tool call. '
f'Pending message: {pending_message}'
)
if all(
tool_call.id in tool_call_id_to_message
for tool_call in pending_message.tool_calls
):
# If complete:
# -- 1. Add the message that **initiated** the tool calls
messages_to_add.append(pending_message)
# -- 2. Add the tool calls **results***
for tool_call in pending_message.tool_calls:
messages_to_add.append(tool_call_id_to_message[tool_call.id])
tool_call_id_to_message.pop(tool_call.id)
_response_ids_to_remove.append(response_id)
# Cleanup the processed pending tool messages
for response_id in _response_ids_to_remove:
pending_tool_call_action_messages.pop(response_id)
messages += messages_to_add
return messages
def get_action_message(
action: Action,
pending_tool_call_action_messages: dict[str, Message],
vision_is_active: bool = False,
) -> list[Message]:
"""Converts an action into a message format that can be sent to the LLM.
This method handles different types of actions and formats them appropriately:
1. For tool-based actions (AgentDelegate, CmdRun, IPythonRunCell, FileEdit) and agent-sourced AgentFinish:
- In function calling mode: Stores the LLM's response in pending_tool_call_action_messages
- In non-function calling mode: Creates a message with the action string
2. For MessageActions: Creates a message with the text content and optional image content
Args:
action: The action to convert. Can be one of:
- CmdRunAction: For executing bash commands
- IPythonRunCellAction: For running IPython code
- FileEditAction: For editing files
- FileReadAction: For reading files using openhands-aci commands
- BrowseInteractiveAction: For browsing the web
- AgentFinishAction: For ending the interaction
- MessageAction: For sending messages
pending_tool_call_action_messages: Dictionary mapping response IDs to their corresponding messages.
Used in function calling mode to track tool calls that are waiting for their results.
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included
Returns:
list[Message]: A list containing the formatted message(s) for the action.
May be empty if the action is handled as a tool call in function calling mode.
Note:
In function calling mode, tool-based actions are stored in pending_tool_call_action_messages
rather than being returned immediately. They will be processed later when all corresponding
tool call results are available.
"""
# create a regular message from an event
if isinstance(
action,
(
AgentDelegateAction,
IPythonRunCellAction,
FileEditAction,
FileReadAction,
BrowseInteractiveAction,
BrowseURLAction,
),
) or (isinstance(action, CmdRunAction) and action.source == 'agent'):
tool_metadata = action.tool_call_metadata
assert tool_metadata is not None, (
'Tool call metadata should NOT be None when function calling is enabled. Action: '
+ str(action)
)
llm_response: ModelResponse = tool_metadata.model_response
assistant_msg = llm_response.choices[0].message
# Add the LLM message (assistant) that initiated the tool calls
# (overwrites any previous message with the same response_id)
logger.debug(
f'Tool calls type: {type(assistant_msg.tool_calls)}, value: {assistant_msg.tool_calls}'
)
pending_tool_call_action_messages[llm_response.id] = Message(
role=assistant_msg.role,
# tool call content SHOULD BE a string
content=[TextContent(text=assistant_msg.content or '')]
if assistant_msg.content is not None
else [],
tool_calls=assistant_msg.tool_calls,
)
return []
elif isinstance(action, AgentFinishAction):
role = 'user' if action.source == 'user' else 'assistant'
# when agent finishes, it has tool_metadata
# which has already been executed, and it doesn't have a response
# when the user finishes (/exit), we don't have tool_metadata
tool_metadata = action.tool_call_metadata
if tool_metadata is not None:
# take the response message from the tool call
assistant_msg = tool_metadata.model_response.choices[0].message
content = assistant_msg.content or ''
# save content if any, to thought
if action.thought:
if action.thought != content:
action.thought += '\n' + content
else:
action.thought = content
# remove the tool call metadata
action.tool_call_metadata = None
return [
Message(
role=role,
content=[TextContent(text=action.thought)],
)
]
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
if vision_is_active and action.image_urls:
content.append(ImageContent(image_urls=action.image_urls))
return [
Message(
role=role,
content=content,
)
]
elif isinstance(action, CmdRunAction) and action.source == 'user':
content = [TextContent(text=f'User executed the command:\n{action.command}')]
return [
Message(
role='user',
content=content,
)
]
return []
def get_observation_message(
obs: Observation,
tool_call_id_to_message: dict[str, Message],
max_message_chars: int | None = None,
vision_is_active: bool = False,
enable_som_visual_browsing: bool = False,
) -> list[Message]:
"""Converts an observation into a message format that can be sent to the LLM.
This method handles different types of observations and formats them appropriately:
- CmdOutputObservation: Formats command execution results with exit codes
- IPythonRunCellObservation: Formats IPython cell execution results, replacing base64 images
- FileEditObservation: Formats file editing results
- FileReadObservation: Formats file reading results from openhands-aci
- AgentDelegateObservation: Formats results from delegated agent tasks
- ErrorObservation: Formats error messages from failed actions
- UserRejectObservation: Formats user rejection messages
In function calling mode, observations with tool_call_metadata are stored in
tool_call_id_to_message for later processing instead of being returned immediately.
Args:
obs: The observation to convert
tool_call_id_to_message: Dictionary mapping tool call IDs to their corresponding messages (used in function calling mode)
max_message_chars: The maximum number of characters in the content of an observation included in the prompt to the LLM
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included
enable_som_visual_browsing: Whether to enable visual browsing for the SOM model
Returns:
list[Message]: A list containing the formatted message(s) for the observation.
May be empty if the observation is handled as a tool response in function calling mode.
Raises:
ValueError: If the observation type is unknown
"""
message: Message
if isinstance(obs, CmdOutputObservation):
# if it doesn't have tool call metadata, it was triggered by a user action
if obs.tool_call_metadata is None:
text = truncate_content(
f'\nObserved result of command executed by user:\n{obs.to_agent_observation()}',
max_message_chars,
)
else:
text = truncate_content(obs.to_agent_observation(), max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, IPythonRunCellObservation):
text = obs.content
# replace base64 images with a placeholder
splitted = text.split('\n')
for i, line in enumerate(splitted):
if '![image](data:image/png;base64,' in line:
splitted[i] = (
'![image](data:image/png;base64, ...) already displayed to user'
)
text = '\n'.join(splitted)
text = truncate_content(text, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, FileEditObservation):
text = truncate_content(str(obs), max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, FileReadObservation):
message = Message(
role='user', content=[TextContent(text=obs.content)]
) # Content is already truncated by openhands-aci
elif isinstance(obs, BrowserOutputObservation):
text = obs.get_agent_obs_text()
if (
obs.trigger_by_action == ActionType.BROWSE_INTERACTIVE
and obs.set_of_marks is not None
and len(obs.set_of_marks) > 0
and enable_som_visual_browsing
and vision_is_active
):
text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.)\n'
message = Message(
role='user',
content=[
TextContent(text=text),
ImageContent(image_urls=[obs.set_of_marks]),
],
)
else:
message = Message(
role='user',
content=[TextContent(text=text)],
)
elif isinstance(obs, AgentDelegateObservation):
text = truncate_content(
obs.outputs['content'] if 'content' in obs.outputs else '',
max_message_chars,
)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, ErrorObservation):
text = truncate_content(obs.content, max_message_chars)
text += '\n[Error occurred in processing last action]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, UserRejectObservation):
text = 'OBSERVATION:\n' + truncate_content(obs.content, max_message_chars)
text += '\n[Last action has been rejected by the user]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, AgentCondensationObservation):
text = truncate_content(obs.content, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
else:
# If an observation message is not returned, it will cause an error
# when the LLM tries to return the next message
raise ValueError(f'Unknown observation type: {type(obs)}')
# Update the message as tool response properly
if (tool_call_metadata := obs.tool_call_metadata) is not None:
tool_call_id_to_message[tool_call_metadata.tool_call_id] = Message(
role='tool',
content=message.content,
tool_call_id=tool_call_metadata.tool_call_id,
name=tool_call_metadata.function_name,
)
# No need to return the observation message
# because it will be added by get_action_message when all the corresponding
# tool calls in the SAME request are processed
return []
return [message]
def apply_prompt_caching(messages: list[Message]) -> None:
"""Applies caching breakpoints to the messages."""
# NOTE: this is only needed for anthropic
# following logic here:
# https://github.com/anthropics/anthropic-quickstarts/blob/8f734fd08c425c6ec91ddd613af04ff87d70c5a0/computer-use-demo/computer_use_demo/loop.py#L241-L262
breakpoints_remaining = 3 # remaining 1 for system/tool
for message in reversed(messages):
if message.role in ('user', 'tool'):
if breakpoints_remaining > 0:
message.content[
-1
].cache_prompt = True # Last item inside the message content
breakpoints_remaining -= 1
else:
break
+2 -2
View File
@@ -130,9 +130,9 @@ def event_to_memory(event: 'Event', max_message_chars: int) -> dict:
return d
def truncate_content(content: str, max_chars: int) -> str:
def truncate_content(content: str, max_chars: int | None = None) -> str:
"""Truncate the middle of the observation content if it is too long."""
if len(content) <= max_chars or max_chars == -1:
if max_chars is None or len(content) <= max_chars or max_chars < 0:
return content
# truncate the middle and include a message to the LLM about it
@@ -57,6 +57,7 @@ from openhands.runtime.browser.browser_env import BrowserEnv
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.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
@@ -171,12 +172,19 @@ class ActionExecutor:
else:
logger.info('No max memory limit set, using all available system memory')
self.memory_monitor = MemoryMonitor(
enable=os.environ.get('RUNTIME_MEMORY_MONITOR', 'False').lower()
in ['true', '1', 'yes']
)
self.memory_monitor.start_monitoring()
@property
def initial_cwd(self):
return self._initial_cwd
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
@@ -186,15 +194,18 @@ class ActionExecutor:
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
self.bash_session.initialize()
logger.debug('Bash session initialized')
await wait_all(
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
timeout=30,
)
logger.debug('All plugins initialized')
# This is a temporary workaround
# TODO: refactor AgentSkills to be part of JupyterPlugin
# AFTER ServerRuntime is deprecated
logger.debug('Initializing AgentSkills')
if 'agent_skills' in self.plugins and 'jupyter' in self.plugins:
obs = await self.run_ipython(
IPythonRunCellAction(
@@ -203,6 +214,7 @@ class ActionExecutor:
)
logger.debug(f'AgentSkills initialized: {obs}')
logger.debug('Initializing bash commands')
await self._init_bash_commands()
logger.debug('Runtime client initialized.')
self._initialized = True
@@ -447,6 +459,7 @@ class ActionExecutor:
return await browse(action, self.browser)
def close(self):
self.memory_monitor.stop_monitoring()
if self.bash_session is not None:
self.bash_session.close()
self.browser.close()
@@ -255,7 +255,6 @@ class DockerRuntime(ActionExecutionClient):
server_port=self._container_port,
plugins=self.plugins,
app_config=self.config,
use_nice_for_root=False,
)
try:
@@ -75,6 +75,8 @@ class RemoteRuntime(ActionExecutionClient):
'remote_runtime_api_url is required in the remote runtime.'
)
assert self.config.sandbox.remote_runtime_class in (None, 'sysbox', 'gvisor')
self.runtime_builder = RemoteRuntimeBuilder(
self.config.sandbox.remote_runtime_api_url,
self.config.sandbox.api_key,
@@ -225,6 +227,9 @@ class RemoteRuntime(ActionExecutionClient):
'session_id': self.sid,
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
}
if self.config.sandbox.remote_runtime_class == 'sysbox':
start_request['runtime_class'] = 'sysbox-runc'
# We ignore other runtime classes for now, because both None and 'gvisor' map to 'gvisor'
# Start the sandbox using the /start endpoint
try:
+1 -16
View File
@@ -16,7 +16,6 @@ def get_action_execution_server_startup_command(
plugins: list[PluginRequirement],
app_config: AppConfig,
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
use_nice_for_root: bool = True,
override_user_id: int | None = None,
override_username: str | None = None,
):
@@ -40,7 +39,6 @@ def get_action_execution_server_startup_command(
user_id = override_user_id or (
sandbox_config.user_id if app_config.run_as_openhands else 0
)
is_root = bool(username == 'root')
base_cmd = [
*python_prefix,
@@ -59,17 +57,4 @@ def get_action_execution_server_startup_command(
*browsergym_args,
]
if is_root and use_nice_for_root:
# If running as root, set highest priority and lowest OOM score
cmd_str = ' '.join(base_cmd)
return [
'nice',
'-n',
'-20', # Highest priority
'sh',
'-c',
f'echo -1000 > /proc/self/oom_score_adj && exec {cmd_str}',
]
else:
# If not root OR not using nice for root, run with normal priority
return base_cmd
return base_cmd
+66
View File
@@ -0,0 +1,66 @@
"""Memory monitoring utilities for the runtime."""
import threading
from memory_profiler import memory_usage
from openhands.core.logger import openhands_logger as logger
class LogStream:
"""Stream-like object that redirects writes to a logger."""
def write(self, message):
if message and not message.isspace():
logger.info(f'[Memory usage] {message.strip()}')
def flush(self):
pass
class MemoryMonitor:
def __init__(self, enable: bool = False):
"""Memory monitor for the runtime."""
self._monitoring_thread: threading.Thread | None = None
self._stop_monitoring = threading.Event()
self.log_stream = LogStream()
self.enable = enable
def start_monitoring(self):
"""Start monitoring memory usage."""
if not self.enable:
return
if self._monitoring_thread is not None:
return
def monitor_process():
try:
# Use memory_usage's built-in monitoring loop
mem_usage = memory_usage(
-1, # Monitor current process
interval=0.1, # Check every second
timeout=3600, # Run indefinitely
max_usage=False, # Get continuous readings
include_children=True, # Include child processes
multiprocess=True, # Monitor all processes
stream=self.log_stream, # Redirect output to logger
backend='psutil_pss',
)
logger.info(f'Memory usage across time: {mem_usage}')
except Exception as e:
logger.error(f'Memory monitoring failed: {e}')
self._monitoring_thread = threading.Thread(target=monitor_process, daemon=True)
self._monitoring_thread.start()
logger.info('Memory monitoring started')
def stop_monitoring(self):
"""Stop monitoring memory usage."""
if not self.enable:
return
if self._monitoring_thread is not None:
self._stop_monitoring.set()
self._monitoring_thread = None
logger.info('Memory monitoring stopped')
+2
View File
@@ -41,7 +41,9 @@ def send_request(
timeout: int = 10,
**kwargs: Any,
) -> requests.Response:
print(f'Sending {method} request to {url} with timeout {timeout}: {kwargs}')
response = session.request(method, url, timeout=timeout, **kwargs)
print(f'Response: {response.status_code} {response.text}')
try:
response.raise_for_status()
except requests.HTTPError as e:
@@ -100,6 +100,7 @@ async def _create_new_conversation(
title=conversation_title,
github_user_id=user_id,
selected_repository=selected_repository,
selected_branch=selected_branch,
)
)
@@ -7,6 +7,7 @@ class ConversationMetadata:
conversation_id: str
github_user_id: str | None
selected_repository: str | None
selected_branch: str | None = None
title: str | None = None
last_updated_at: datetime | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
Generated
+1689 -1542
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -46,7 +46,7 @@ pathspec = "^0.12.1"
google-cloud-aiplatform = "*"
anthropic = {extras = ["vertex"], version = "*"}
grep-ast = "0.3.3"
tree-sitter = "0.21.3"
tree-sitter = "^0.24.0"
bashlex = "^0.18"
pyjwt = "^2.9.0"
dirhash = "*"
@@ -67,12 +67,15 @@ runloop-api-client = "0.23.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
openhands-aci = "^0.2.2"
openhands-aci = "^0.2.3"
python-socketio = "^5.11.4"
redis = "^5.2.0"
sse-starlette = "^2.1.3"
psutil = "*"
stripe = "^11.5.0"
ipywidgets = "^8.1.5"
qtconsole = "^5.6.1"
memory-profiler = "^0.61.0"
[tool.poetry.group.llama-index.dependencies]
llama-index = "*"
+388 -136
View File
@@ -1,8 +1,21 @@
"""Bash-related tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox."""
"""Bash-related tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox.
Example usage:
```bash
export ALLHANDS_API_KEY="YOUR_API_KEY"
export RUNTIME=remote
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.staging.all-hands.dev"
poetry run pytest -vvxss tests/runtime/test_stress_remote_runtime.py
```
"""
import asyncio
import os
import tempfile
import time
from datetime import datetime
from unittest.mock import MagicMock
import pandas as pd
@@ -30,7 +43,12 @@ from openhands.core.config import (
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import CmdRunAction, MessageAction
from openhands.events.action import (
CmdRunAction,
FileEditAction,
FileWriteAction,
MessageAction,
)
from openhands.events.observation import CmdOutputObservation
from openhands.events.serialization.event import event_to_dict
from openhands.llm import LLM
@@ -42,20 +60,10 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
}
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
assert (
os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL') is not None
), 'SANDBOX_REMOTE_RUNTIME_API_URL must be set.'
assert (
os.environ.get('ALLHANDS_API_KEY') is not None
), 'ALLHANDS_API_KEY must be set.'
def get_config() -> AppConfig:
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime='remote',
runtime=os.environ.get('RUNTIME', 'remote'),
sandbox=SandboxConfig(
base_container_image='python:3.11-bookworm',
enable_auto_lint=True,
@@ -63,8 +71,11 @@ def get_config(
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_api_url=os.environ.get(
'SANDBOX_REMOTE_RUNTIME_API_URL', None
),
keep_runtime_alive=False,
remote_runtime_resource_factor=1,
),
# do not mount workspace
workspace_base=None,
@@ -79,132 +90,130 @@ def get_config(
return config
def initialize_runtime(
runtime: Runtime,
):
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info('-' * 30)
logger.info('BEGIN Runtime Initialization Fn')
logger.info('-' * 30)
obs: CmdOutputObservation
action = CmdRunAction(command="""export USER=$(whoami); echo USER=${USER} """)
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(obs.exit_code == 0, f'Failed to export USER: {str(obs)}')
action = CmdRunAction(command='mkdir -p /dummy_dir')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0,
f'Failed to create /dummy_dir: {str(obs)}',
)
with tempfile.TemporaryDirectory() as temp_dir:
# Construct the full path for the desired file name within the temporary directory
temp_file_path = os.path.join(temp_dir, 'dummy_file')
# Write to the file with the desired name within the temporary directory
with open(temp_file_path, 'w') as f:
f.write('dummy content')
# Copy the file to the desired location
runtime.copy_to(temp_file_path, '/dummy_dir/')
logger.info('-' * 30)
logger.info('END Runtime Initialization Fn')
logger.info('-' * 30)
def process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
) -> EvalOutput:
config = get_config(metadata)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
runtime = create_runtime(config, headless_mode=False)
call_async_from_sync(runtime.connect)
try:
initialize_runtime(runtime)
instruction = 'dummy instruction'
agent = Agent.get_cls(metadata.agent_class)(
llm=LLM(config=metadata.llm_config),
config=config.get_agent_config(metadata.agent_class),
)
def next_command(*args, **kwargs):
return CmdRunAction(command='ls -lah')
agent.step = MagicMock(side_effect=next_command)
# Here's how you can run the agent (similar to the `main` function) and get the final task state
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=MessageAction(content=instruction),
runtime=runtime,
fake_user_response_fn=AGENT_CLS_TO_FAKE_USER_RESPONSE_FN[
metadata.agent_class
],
agent=agent,
)
)
# if fatal error, throw EvalError to trigger re-run
if (
state.last_error
and 'fatal error during agent execution' in state.last_error
and 'stuck in a loop' not in state.last_error
):
raise EvalException('Fatal error detected: ' + state.last_error)
finally:
runtime.close()
test_result = {}
if state is None:
raise ValueError('State should not be None.')
histories = [event_to_dict(event) for event in state.history]
metrics = state.metrics.get() if state.metrics else None
# Save the output
output = EvalOutput(
instance_id=instance.instance_id,
instruction=instruction,
instance=instance.to_dict(), # SWE Bench specific
test_result=test_result,
metadata=metadata,
history=histories,
metrics=metrics,
error=state.last_error if state and state.last_error else None,
)
return output
@pytest.mark.skipif(
TEST_IN_CI,
reason='This test should only be run locally, not in CI.',
)
def test_stress_remote_runtime(n_eval_workers: int = 64):
def test_stress_remote_runtime_eval(n_eval_workers: int = 64):
"""Mimic evaluation setting to test remote runtime in a multi-processing setting."""
def _initialize_runtime(
runtime: Runtime,
):
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info('-' * 30)
logger.info('BEGIN Runtime Initialization Fn')
logger.info('-' * 30)
obs: CmdOutputObservation
action = CmdRunAction(command="""export USER=$(whoami); echo USER=${USER} """)
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(obs.exit_code == 0, f'Failed to export USER: {str(obs)}')
action = CmdRunAction(command='mkdir -p /dummy_dir')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0,
f'Failed to create /dummy_dir: {str(obs)}',
)
with tempfile.TemporaryDirectory() as temp_dir:
# Construct the full path for the desired file name within the temporary directory
temp_file_path = os.path.join(temp_dir, 'dummy_file')
# Write to the file with the desired name within the temporary directory
with open(temp_file_path, 'w') as f:
f.write('dummy content')
# Copy the file to the desired location
runtime.copy_to(temp_file_path, '/dummy_dir/')
logger.info('-' * 30)
logger.info('END Runtime Initialization Fn')
logger.info('-' * 30)
def _process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
) -> EvalOutput:
config = get_config()
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
runtime = create_runtime(config, headless_mode=True)
call_async_from_sync(runtime.connect)
try:
_initialize_runtime(runtime)
instruction = 'dummy instruction'
agent = Agent.get_cls(metadata.agent_class)(
llm=LLM(config=metadata.llm_config),
config=config.get_agent_config(metadata.agent_class),
)
def next_command(*args, **kwargs):
return CmdRunAction(command='ls -lah')
agent.step = MagicMock(side_effect=next_command)
# Here's how you can run the agent (similar to the `main` function) and get the final task state
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=MessageAction(content=instruction),
runtime=runtime,
fake_user_response_fn=AGENT_CLS_TO_FAKE_USER_RESPONSE_FN[
metadata.agent_class
],
agent=agent,
)
)
# if fatal error, throw EvalError to trigger re-run
if (
state.last_error
and 'fatal error during agent execution' in state.last_error
and 'stuck in a loop' not in state.last_error
):
raise EvalException('Fatal error detected: ' + state.last_error)
finally:
runtime.close()
test_result = {}
if state is None:
raise ValueError('State should not be None.')
histories = [event_to_dict(event) for event in state.history]
metrics = state.metrics.get() if state.metrics else None
# Save the output
output = EvalOutput(
instance_id=instance.instance_id,
instruction=instruction,
instance=instance.to_dict(), # SWE Bench specific
test_result=test_result,
metadata=metadata,
history=histories,
metrics=metrics,
error=state.last_error if state and state.last_error else None,
)
return output
llm_config = LLMConfig()
metadata = make_metadata(
llm_config,
@@ -228,4 +237,247 @@ def test_stress_remote_runtime(n_eval_workers: int = 64):
dummy_instance, output_file, eval_n_limit=len(dummy_instance)
)
run_evaluation(instances, metadata, output_file, n_eval_workers, process_instance)
run_evaluation(instances, metadata, output_file, n_eval_workers, _process_instance)
@pytest.mark.skipif(
TEST_IN_CI,
reason='This test should only be run locally, not in CI.',
)
def test_stress_remote_runtime_long_output_with_soft_and_hard_timeout():
"""Stress test for the remote runtime."""
config = get_config()
try:
runtime = create_runtime(config, headless_mode=True)
call_async_from_sync(runtime.connect)
_time_for_test = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
# Run a command that generates long output multiple times
for i in range(10):
start_time = time.time()
iteration_stats = {
'iteration': i,
'timestamp': time.time(),
}
# Check overall system memory usage
mem_action = CmdRunAction(
'free -k | grep "Mem:" | awk \'{printf "Total: %8.1f MB, Used: %8.1f MB, Free: %8.1f MB, Available: %8.1f MB\\n", $2/1024, $3/1024, $4/1024, $7/1024}\''
)
mem_obs = runtime.run_action(mem_action)
assert mem_obs.exit_code == 0
logger.info(
f'System memory usage (iteration {i}): {mem_obs.content.strip()}'
)
# Parse memory values from output
mem_parts = mem_obs.content.strip().split(',')
for part in mem_parts:
key, value = part.strip().split(':')
iteration_stats[f'memory_{key.lower()}'] = float(
value.replace('MB', '').strip()
)
# Check top memory-consuming processes
mem_action = CmdRunAction(
'ps aux | awk \'{printf "%8.1f MB %s\\n", $6/1024, $0}\' | sort -nr | head -n 5'
)
mem_obs = runtime.run_action(mem_action)
assert mem_obs.exit_code == 0
_top_processes = [i.strip() for i in mem_obs.content.strip().split('\n')]
logger.info(
f'Top 5 memory-consuming processes (iteration {i}):\n{"- " + "\n- ".join(_top_processes)}'
)
iteration_stats['top_processes'] = _top_processes
# Check tmux memory usage (in KB)
mem_action = CmdRunAction(
'ps aux | awk \'{printf "%8.1f MB %s\\n", $6/1024, $0}\' | sort -nr | grep "/usr/bin/tmux" | grep -v grep | awk \'{print $1}\''
)
mem_obs = runtime.run_action(mem_action)
assert mem_obs.exit_code == 0
logger.info(
f'Tmux memory usage (iteration {i}): {mem_obs.content.strip()} KB'
)
try:
iteration_stats['tmux_memory_mb'] = float(mem_obs.content.strip())
except (ValueError, AttributeError):
iteration_stats['tmux_memory_mb'] = None
# Check action_execution_server mem
mem_action = CmdRunAction(
'ps aux | awk \'{printf "%8.1f MB %s\\n", $6/1024, $0}\' | sort -nr | grep "action_execution_server" | grep "/openhands/poetry" | grep -v grep | awk \'{print $1}\''
)
mem_obs = runtime.run_action(mem_action)
assert mem_obs.exit_code == 0
logger.info(
f'Action execution server memory usage (iteration {i}): {mem_obs.content.strip()} MB'
)
try:
iteration_stats['action_server_memory_mb'] = float(
mem_obs.content.strip()
)
except (ValueError, AttributeError):
iteration_stats['action_server_memory_mb'] = None
# Test soft timeout
action = CmdRunAction(
'read -p "Do you want to continue? [Y/n] " answer; if [[ $answer == "Y" ]]; then echo "Proceeding with operation..."; echo "Operation completed successfully!"; else echo "Operation cancelled."; exit 1; fi'
)
obs = runtime.run_action(action)
assert 'Do you want to continue?' in obs.content
assert obs.exit_code == -1 # Command is still running, waiting for input
# Send the confirmation
action = CmdRunAction('Y', is_input=True)
obs = runtime.run_action(action)
assert 'Proceeding with operation...' in obs.content
assert 'Operation completed successfully!' in obs.content
assert obs.exit_code == 0
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
# Test hard timeout w/ long output
# Generate long output with 1000 asterisks per line
action = CmdRunAction(
f'export i={i}; for j in $(seq 1 100); do echo "Line $j - Iteration $i - $(printf \'%1000s\' | tr " " "*")"; sleep 1; done'
)
action.set_hard_timeout(2)
obs = runtime.run_action(action)
# Verify the output
assert obs.exit_code == -1
assert f'Line 1 - Iteration {i}' in obs.content
# Because hard-timeout is triggered, the terminal will in a weird state
# where it will not accept any new commands.
obs = runtime.run_action(CmdRunAction('ls'))
assert obs.exit_code == -1
assert 'The previous command is still running' in obs.metadata.suffix
# We need to send a Ctrl+C to reset the terminal.
obs = runtime.run_action(CmdRunAction('C-c', is_input=True))
assert obs.exit_code == 130
# Now make sure the terminal is in a good state
obs = runtime.run_action(CmdRunAction('ls'))
assert obs.exit_code == 0
duration = time.time() - start_time
iteration_stats['duration'] = duration
logger.info(f'Completed iteration {i} in {duration:.2f} seconds')
finally:
runtime.close()
@pytest.mark.skipif(
TEST_IN_CI,
reason='This test should only be run locally, not in CI.',
)
def test_stress_runtime_memory_limits():
"""Test runtime behavior under resource constraints."""
config = get_config()
# For Docker runtime, add resource constraints
if config.runtime == 'docker':
config.sandbox.docker_runtime_kwargs = {
'cpu_period': 100000, # 100ms
'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU)
'mem_limit': '4G', # 4 GB of memory
'memswap_limit': '0', # No swap
'mem_swappiness': 0, # Disable swapping
'oom_kill_disable': False, # Enable OOM killer
}
config.sandbox.runtime_startup_env_vars = {
'RUNTIME_MAX_MEMORY_GB': '3',
'RUNTIME_MEMORY_MONITOR': 'true',
}
try:
runtime = create_runtime(config, headless_mode=True)
call_async_from_sync(runtime.connect)
# Install stress-ng
action = CmdRunAction(
command='sudo apt-get update && sudo apt-get install -y stress-ng'
)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
action = CmdRunAction(
command='stress-ng --vm 1 --vm-bytes 6G --timeout 1m --metrics'
)
action.set_hard_timeout(120)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'aborted early, out of system resources' in obs.content
assert obs.exit_code == 3 # OOM killed!
finally:
runtime.close()
@pytest.mark.skipif(
TEST_IN_CI,
reason='This test should only be run locally, not in CI.',
)
def test_stress_runtime_memory_limits_with_repeated_file_edit():
"""Test runtime behavior under resource constraints with repeated file edits."""
config = get_config()
# For Docker runtime, add resource constraints
if config.runtime == 'docker':
config.sandbox.docker_runtime_kwargs = {
'cpu_period': 100000, # 100ms
'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU)
'mem_limit': '4G', # 4 GB of memory
'memswap_limit': '0', # No swap
'mem_swappiness': 0, # Disable swapping
'oom_kill_disable': False, # Enable OOM killer
}
config.sandbox.runtime_startup_env_vars = {
'RUNTIME_MAX_MEMORY_GB': '3',
'RUNTIME_MEMORY_MONITOR': 'true',
}
try:
runtime = create_runtime(config, headless_mode=True)
call_async_from_sync(runtime.connect)
# Create initial test file with base content
test_file = '/tmp/test_file.txt'
# base_content = 'content_1\n' * 1000 # Create a reasonably sized file
base_content = ''
for i in range(1000):
base_content += f'content_{i:03d}\n'
# Use FileWriteAction to create initial file
write_action = FileWriteAction(path=test_file, content=base_content)
obs = runtime.run_action(write_action)
# Perform repeated file edits
for i in range(1000):
# Use FileEditAction with str_replace instead of IPythonRunCellAction
edit_action = FileEditAction(
command='str_replace',
path=test_file,
old_str=f'content_{i:03d}',
new_str=f'-content_{i:03d}',
)
obs = runtime.run_action(edit_action)
assert (
f'The file {test_file} has been edited' in obs.content
), f'Edit failed at iteration {i}'
logger.info(f'finished iteration {i}')
# Verify final file state using FileEditAction view command
action = FileEditAction(command='view', path=test_file)
obs = runtime.run_action(action)
assert '-content_999' in obs.content, 'Final content verification failed'
logger.info('Final file content verified successfully')
finally:
runtime.close()
+1 -258
View File
@@ -19,23 +19,14 @@ from openhands.agenthub.codeact_agent.function_calling import (
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig, LLMConfig
from openhands.core.exceptions import FunctionCallNotExistsError
from openhands.core.message import ImageContent, TextContent
from openhands.events.action import (
AgentFinishAction,
CmdRunAction,
MessageAction,
)
from openhands.events.event import EventSource, FileEditSource, FileReadSource
from openhands.events.observation.browse import BrowserOutputObservation
from openhands.events.event import EventSource
from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
IPythonRunCellObservation,
)
from openhands.events.observation.delegate import AgentDelegateObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.files import FileEditObservation, FileReadObservation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.tool import ToolCallMetadata
from openhands.llm.llm import LLM
@@ -59,254 +50,6 @@ def mock_state() -> State:
return state
def test_cmd_output_observation_message(agent: CodeActAgent):
obs = CmdOutputObservation(
command='echo hello',
content='Command output',
metadata=CmdOutputMetadata(
exit_code=0,
prefix='[THIS IS PREFIX]',
suffix='[THIS IS SUFFIX]',
),
)
tool_call_id_to_message = {}
results = agent.get_observation_message(
obs, tool_call_id_to_message=tool_call_id_to_message
)
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Observed result of command executed by user:' in result.content[0].text
assert '[Command finished with exit code 0]' in result.content[0].text
assert '[THIS IS PREFIX]' in result.content[0].text
assert '[THIS IS SUFFIX]' in result.content[0].text
def test_ipython_run_cell_observation_message(agent: CodeActAgent):
obs = IPythonRunCellObservation(
code='plt.plot()',
content='IPython output\n![image](data:image/png;base64,ABC123)',
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'IPython output' in result.content[0].text
assert (
'![image](data:image/png;base64, ...) already displayed to user'
in result.content[0].text
)
assert 'ABC123' not in result.content[0].text
def test_agent_delegate_observation_message(agent: CodeActAgent):
obs = AgentDelegateObservation(
content='Content', outputs={'content': 'Delegated agent output'}
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Delegated agent output' in result.content[0].text
def test_error_observation_message(agent: CodeActAgent):
obs = ErrorObservation('Error message')
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Error message' in result.content[0].text
assert 'Error occurred in processing last action' in result.content[0].text
def test_unknown_observation_message(agent: CodeActAgent):
obs = Mock()
with pytest.raises(ValueError, match='Unknown observation type'):
agent.get_observation_message(obs, tool_call_id_to_message={})
def test_file_edit_observation_message(agent: CodeActAgent):
obs = FileEditObservation(
path='/test/file.txt',
prev_exist=True,
old_content='old content',
new_content='new content',
content='diff content',
impl_source=FileEditSource.LLM_BASED_EDIT,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert '[Existing file /test/file.txt is edited with' in result.content[0].text
def test_file_read_observation_message(agent: CodeActAgent):
obs = FileReadObservation(
path='/test/file.txt',
content='File content',
impl_source=FileReadSource.DEFAULT,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == 'File content'
def test_browser_output_observation_message(agent: CodeActAgent):
obs = BrowserOutputObservation(
url='http://example.com',
trigger_by_action='browse',
screenshot='',
content='Page loaded',
error=False,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert '[Current URL: http://example.com]' in result.content[0].text
def test_user_reject_observation_message(agent: CodeActAgent):
obs = UserRejectObservation('Action rejected')
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Action rejected' in result.content[0].text
assert '[Last action has been rejected by the user]' in result.content[0].text
def test_function_calling_observation_message(agent: CodeActAgent):
mock_response = {
'id': 'mock_id',
'total_calls_in_response': 1,
'choices': [{'message': {'content': 'Task completed'}}],
}
obs = CmdOutputObservation(
command='echo hello',
content='Command output',
command_id=1,
exit_code=0,
)
obs.tool_call_metadata = ToolCallMetadata(
tool_call_id='123',
function_name='execute_bash',
model_response=mock_response,
total_calls_in_response=1,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 0 # No direct message when using function calling
def test_message_action_with_image(agent: CodeActAgent):
action = MessageAction(
content='Message with image',
image_urls=['http://example.com/image.jpg'],
)
action._source = EventSource.AGENT
results = agent.get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'assistant'
assert len(result.content) == 2
assert isinstance(result.content[0], TextContent)
assert isinstance(result.content[1], ImageContent)
assert result.content[0].text == 'Message with image'
assert result.content[1].image_urls == ['http://example.com/image.jpg']
def test_user_cmd_action_message(agent: CodeActAgent):
action = CmdRunAction(command='ls -l')
action._source = EventSource.USER
results = agent.get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'User executed the command' in result.content[0].text
assert 'ls -l' in result.content[0].text
def test_agent_finish_action_with_tool_metadata(agent: CodeActAgent):
mock_response = {
'id': 'mock_id',
'total_calls_in_response': 1,
'choices': [{'message': {'content': 'Task completed'}}],
}
action = AgentFinishAction(thought='Initial thought')
action._source = EventSource.AGENT
action.tool_call_metadata = ToolCallMetadata(
tool_call_id='123',
function_name='finish',
model_response=mock_response,
total_calls_in_response=1,
)
results = agent.get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'assistant'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Initial thought\nTask completed' in result.content[0].text
def test_reset(agent: CodeActAgent):
# Add some state
action = MessageAction(content='test')
+271
View File
@@ -0,0 +1,271 @@
from unittest.mock import Mock
import pytest
from openhands.core.message import ImageContent, TextContent
from openhands.core.message_utils import get_action_message, get_observation_message
from openhands.events.action import (
AgentFinishAction,
CmdRunAction,
MessageAction,
)
from openhands.events.event import EventSource, FileEditSource, FileReadSource
from openhands.events.observation.browse import BrowserOutputObservation
from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
IPythonRunCellObservation,
)
from openhands.events.observation.delegate import AgentDelegateObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.files import FileEditObservation, FileReadObservation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.tool import ToolCallMetadata
def test_cmd_output_observation_message():
obs = CmdOutputObservation(
command='echo hello',
content='Command output',
metadata=CmdOutputMetadata(
exit_code=0,
prefix='[THIS IS PREFIX]',
suffix='[THIS IS SUFFIX]',
),
)
tool_call_id_to_message = {}
results = get_observation_message(
obs, tool_call_id_to_message=tool_call_id_to_message
)
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Observed result of command executed by user:' in result.content[0].text
assert '[Command finished with exit code 0]' in result.content[0].text
assert '[THIS IS PREFIX]' in result.content[0].text
assert '[THIS IS SUFFIX]' in result.content[0].text
def test_ipython_run_cell_observation_message():
obs = IPythonRunCellObservation(
code='plt.plot()',
content='IPython output\n![image](data:image/png;base64,ABC123)',
)
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'IPython output' in result.content[0].text
assert (
'![image](data:image/png;base64, ...) already displayed to user'
in result.content[0].text
)
assert 'ABC123' not in result.content[0].text
def test_agent_delegate_observation_message():
obs = AgentDelegateObservation(
content='Content', outputs={'content': 'Delegated agent output'}
)
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Delegated agent output' in result.content[0].text
def test_error_observation_message():
obs = ErrorObservation('Error message')
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Error message' in result.content[0].text
assert 'Error occurred in processing last action' in result.content[0].text
def test_unknown_observation_message():
obs = Mock()
with pytest.raises(ValueError, match='Unknown observation type'):
get_observation_message(obs, tool_call_id_to_message={})
def test_file_edit_observation_message():
obs = FileEditObservation(
path='/test/file.txt',
prev_exist=True,
old_content='old content',
new_content='new content',
content='diff content',
impl_source=FileEditSource.LLM_BASED_EDIT,
)
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert '[Existing file /test/file.txt is edited with' in result.content[0].text
def test_file_read_observation_message():
obs = FileReadObservation(
path='/test/file.txt',
content='File content',
impl_source=FileReadSource.DEFAULT,
)
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == 'File content'
def test_browser_output_observation_message():
obs = BrowserOutputObservation(
url='http://example.com',
trigger_by_action='browse',
screenshot='',
content='Page loaded',
error=False,
)
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert '[Current URL: http://example.com]' in result.content[0].text
def test_user_reject_observation_message():
obs = UserRejectObservation('Action rejected')
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Action rejected' in result.content[0].text
assert '[Last action has been rejected by the user]' in result.content[0].text
def test_function_calling_observation_message():
mock_response = {
'id': 'mock_id',
'total_calls_in_response': 1,
'choices': [{'message': {'content': 'Task completed'}}],
}
obs = CmdOutputObservation(
command='echo hello',
content='Command output',
command_id=1,
exit_code=0,
)
obs.tool_call_metadata = ToolCallMetadata(
tool_call_id='123',
function_name='execute_bash',
model_response=mock_response,
total_calls_in_response=1,
)
results = get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 0 # No direct message when using function calling
def test_message_action_with_image():
action = MessageAction(
content='Message with image',
image_urls=['http://example.com/image.jpg'],
)
action._source = EventSource.AGENT
results = get_action_message(action, {}, vision_is_active=True)
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'assistant'
assert len(result.content) == 2
assert isinstance(result.content[0], TextContent)
assert isinstance(result.content[1], ImageContent)
assert result.content[0].text == 'Message with image'
assert result.content[1].image_urls == ['http://example.com/image.jpg']
def test_user_cmd_action_message():
action = CmdRunAction(command='ls -l')
action._source = EventSource.USER
results = get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'User executed the command' in result.content[0].text
assert 'ls -l' in result.content[0].text
def test_agent_finish_action_with_tool_metadata():
mock_response = {
'id': 'mock_id',
'total_calls_in_response': 1,
'choices': [{'message': {'content': 'Task completed'}}],
}
action = AgentFinishAction(thought='Initial thought')
action._source = EventSource.AGENT
action.tool_call_metadata = ToolCallMetadata(
tool_call_id='123',
function_name='finish',
model_response=mock_response,
total_calls_in_response=1,
)
results = get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'assistant'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Initial thought\nTask completed' in result.content[0].text