Compare commits

..

6 Commits

Author SHA1 Message Date
mamoodi 81f73ee91d Release 0.56.0 2025-09-08 09:18:07 -04:00
Joe Axe a25826a5f9 fix: resolve empty API keys to None and add Bedrock model support (#10573) 2025-09-08 14:45:10 +02:00
Ryan H. Tran df9320f8ab Implement model routing support (#9738)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-08 16:19:34 +07:00
Boxuan Li af0ab5a9f2 Fix working_dir bug in local runtime (#10801)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-07 23:44:55 -07:00
Ruilin Zhou 9960d11d08 feat(runtime): upgrade E2B runtime to v2.0 with full implementation (#10832) 2025-09-08 06:32:08 +02:00
mamoodi d5d5e265f8 Fix issue #10729: Add x-ai/grok-code-fast-1 to MODELS_WITHOUT_STOP_WORDS (#10867)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-09-08 05:30:45 +02:00
45 changed files with 1015 additions and 446 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.56-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.55
docker.all-hands.dev/all-hands-ai/openhands:0.56
```
</details>
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.55
docker.all-hands.dev/all-hands-ai/openhands:0.56
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.55
docker.all-hands.dev/all-hands-ai/openhands:0.56
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+19
View File
@@ -219,6 +219,14 @@ correct_num = 5
api_key = ""
model = "gpt-4o"
# Example routing LLM configuration for multimodal model routing
# Uncomment and configure to enable model routing with a secondary model
#[llm.secondary_model]
#model = "kimi-k2"
#api_key = ""
#for_routing = true
#max_input_tokens = 128000
#################################### Agent ###################################
# Configuration for agents (group name starts with 'agent')
@@ -480,3 +488,14 @@ type = "noop"
# Run the runtime sandbox container in privileged mode for use with docker-in-docker
#privileged = false
#################################### Model Routing ############################
# Configuration for experimental model routing feature
# Enables intelligent switching between different LLM models for specific purposes
##############################################################################
[model_routing]
# Router to use for model selection
# Available options:
# - "noop_router" (default): No routing, always uses primary LLM
# - "multimodal_router": A router that switches between primary and secondary models, depending on whether the input is multimodal or not
#router_name = "noop_router"
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.56-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -122,7 +122,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
python -m openhands.cli.entry --override-cli-mode true
```
+2 -2
View File
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+4 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.55
docker.all-hands.dev/all-hands-ai/openhands:0.56
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.56
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
+3 -3
View File
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.55
docker.all-hands.dev/all-hands-ai/openhands:0.56
```
</Accordion>
+20 -3
View File
@@ -28,6 +28,7 @@ from evaluation.utils.shared import (
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from openhands.controller.state.state import State
from openhands.core.config import (
@@ -36,7 +37,11 @@ from openhands.core.config import (
get_llm_config_arg,
load_from_toml,
)
from openhands.core.config.utils import get_agent_config_arg
from openhands.core.config.utils import (
get_agent_config_arg,
get_llms_for_routing_config,
get_model_routing_config_arg,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
@@ -57,6 +62,7 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
instance: pd.Series,
metadata: EvalMetadata,
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
@@ -66,13 +72,24 @@ def get_config(
sandbox_config=sandbox_config,
runtime='docker',
)
config.set_llm_config(metadata.llm_config)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
)
)
model_routing_config = get_model_routing_config_arg()
model_routing_config.llms_for_routing = (
get_llms_for_routing_config()
) # Populate with LLMs for routing from config.toml file
if metadata.agent_config:
metadata.agent_config.model_routing = model_routing_config
config.set_agent_config(metadata.agent_config, metadata.agent_class)
else:
logger.info('Agent config not provided, using default settings')
agent_config = config.get_agent_config(metadata.agent_class)
agent_config.enable_prompt_extensions = False
agent_config.model_routing = model_routing_config
config_copy = copy.deepcopy(config)
load_from_toml(config_copy)
@@ -145,7 +162,7 @@ def process_instance(
metadata: EvalMetadata,
reset_logger: bool = True,
) -> EvalOutput:
config = get_config(metadata)
config = get_config(instance, metadata)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
@@ -47,6 +47,8 @@ from openhands.core.config import (
get_agent_config_arg,
get_evaluation_parser,
get_llm_config_arg,
get_llms_for_routing_config,
get_model_routing_config_arg,
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import get_condenser_config_arg
@@ -244,6 +246,11 @@ def get_config(
# get 'draft_editor' config if exists
config.set_llm_config(get_llm_config_arg('draft_editor'), 'draft_editor')
model_routing_config = get_model_routing_config_arg()
model_routing_config.llms_for_routing = (
get_llms_for_routing_config()
) # Populate with LLMs for routing from config.toml file
agent_config = AgentConfig(
enable_jupyter=False,
enable_browsing=RUN_WITH_BROWSING,
@@ -251,8 +258,10 @@ def get_config(
enable_mcp=False,
condenser=metadata.condenser_config,
enable_prompt_extensions=False,
model_routing=model_routing_config,
)
config.set_agent_config(agent_config)
return config
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.55.0",
"version": "0.56.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.55.0",
"version": "0.56.0",
"dependencies": {
"@heroui/react": "^2.8.3",
"@heroui/use-infinite-scroll": "^2.2.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.55.0",
"version": "0.56.0",
"private": true,
"type": "module",
"engines": {
@@ -92,6 +92,9 @@ class CodeActAgent(Agent):
self.condenser = Condenser.from_config(self.config.condenser, llm_registry)
logger.debug(f'Using condenser: {type(self.condenser)}')
# Override with router if needed
self.llm = self.llm_registry.get_router(self.config)
@property
def prompt_manager(self) -> PromptManager:
if self._prompt_manager is None:
+2 -5
View File
@@ -479,11 +479,10 @@ async def modify_llm_settings_basic(
settings = Settings()
settings.llm_model = f'{provider}{organized_models[provider]["separator"]}{model}'
settings.llm_api_key = SecretStr(api_key)
settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
settings.llm_base_url = None
settings.agent = OH_DEFAULT_AGENT
settings.enable_default_condenser = True
await settings_store.store(settings)
@@ -608,12 +607,11 @@ async def modify_llm_settings_advanced(
settings = Settings()
settings.llm_model = custom_model
settings.llm_api_key = SecretStr(api_key)
settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
settings.llm_base_url = base_url
settings.agent = agent
settings.confirmation_mode = enable_confirmation_mode
settings.enable_default_condenser = enable_memory_condensation
await settings_store.store(settings)
@@ -685,5 +683,4 @@ async def modify_search_api_settings(
settings = Settings()
settings.search_api_key = SecretStr(search_api_key) if search_api_key else None
await settings_store.store(settings)
+6
View File
@@ -13,6 +13,7 @@ from openhands.core.config.config_utils import (
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.config.model_routing_config import ModelRoutingConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
@@ -20,6 +21,8 @@ from openhands.core.config.utils import (
finalize_config,
get_agent_config_arg,
get_llm_config_arg,
get_llms_for_routing_config,
get_model_routing_config_arg,
load_from_env,
load_from_toml,
load_openhands_config,
@@ -37,6 +40,7 @@ __all__ = [
'LLMConfig',
'SandboxConfig',
'SecurityConfig',
'ModelRoutingConfig',
'ExtendedConfig',
'load_openhands_config',
'load_from_env',
@@ -50,4 +54,6 @@ __all__ = [
'get_evaluation_parser',
'parse_arguments',
'setup_config_from_args',
'get_model_routing_config_arg',
'get_llms_for_routing_config',
]
+3
View File
@@ -7,6 +7,7 @@ from openhands.core.config.condenser_config import (
ConversationWindowCondenserConfig,
)
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.model_routing_config import ModelRoutingConfig
from openhands.core.logger import openhands_logger as logger
from openhands.utils.import_utils import get_impl
@@ -57,6 +58,8 @@ class AgentConfig(BaseModel):
# handled.
default_factory=lambda: ConversationWindowCondenserConfig()
)
model_routing: ModelRoutingConfig = Field(default_factory=ModelRoutingConfig)
"""Model routing configuration settings."""
extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({}))
"""Extended configuration for the agent."""
+2
View File
@@ -46,6 +46,7 @@ class LLMConfig(BaseModel):
reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Can apply to all reasoning models.
seed: The seed to use for the LLM.
safety_settings: Safety settings for models that support them (like Mistral AI and Gemini).
for_routing: Whether this LLM is used for routing. This is set to True for models used in conjunction with the main LLM in the model routing feature.
"""
model: str = Field(default='claude-sonnet-4-20250514')
@@ -92,6 +93,7 @@ class LLMConfig(BaseModel):
default=None,
description='Safety settings for models that support them (like Mistral AI and Gemini)',
)
for_routing: bool = Field(default=False)
model_config = ConfigDict(extra='forbid')
@@ -0,0 +1,39 @@
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from openhands.core.config.llm_config import LLMConfig
class ModelRoutingConfig(BaseModel):
"""Configuration for model routing.
Attributes:
router_name (str): The name of the router to use. Default is 'noop_router'.
llms_for_routing (dict[str, LLMConfig]): A dictionary mapping config names of LLMs for routing to their configurations.
"""
router_name: str = Field(default='noop_router')
llms_for_routing: dict[str, LLMConfig] = Field(default_factory=dict)
model_config = ConfigDict(extra='forbid')
@classmethod
def from_toml_section(cls, data: dict) -> dict[str, 'ModelRoutingConfig']:
"""
Create a mapping of ModelRoutingConfig instances from a toml dictionary representing the [model_routing] section.
The configuration is built from all keys in data.
Returns:
dict[str, ModelRoutingConfig]: A mapping where the key "model_routing" corresponds to the [model_routing] configuration
"""
# Initialize the result mapping
model_routing_mapping: dict[str, ModelRoutingConfig] = {}
# Try to create the configuration instance
try:
model_routing_mapping['model_routing'] = cls.model_validate(data)
except ValidationError as e:
raise ValueError(f'Invalid model routing configuration: {e}')
return model_routing_mapping
@@ -30,6 +30,7 @@ class OpenHandsConfig(BaseModel):
The default configuration is stored under the 'agent' key.
default_agent: Name of the default agent to use.
sandbox: Sandbox configuration settings.
security: Security configuration settings.
runtime: Runtime environment identifier.
file_store: Type of file store to use.
file_store_path: Path to the file store.
+110
View File
@@ -25,6 +25,7 @@ from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.kubernetes_config import KubernetesConfig
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.config.model_routing_config import ModelRoutingConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
@@ -225,6 +226,35 @@ def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None
# Re-raise ValueError from SecurityConfig.from_toml_section
raise ValueError('Error in [security] section in config.toml')
if 'model_routing' in toml_config:
try:
model_routing_mapping = ModelRoutingConfig.from_toml_section(
toml_config['model_routing']
)
# We only use the base model routing config for now
if 'model_routing' in model_routing_mapping:
default_agent_config = cfg.get_agent_config()
default_agent_config.model_routing = model_routing_mapping[
'model_routing'
]
# Construct the llms_for_routing by filtering llms with for_routing = True
llms_for_routing_dict = {}
for llm_name, llm_config in cfg.llms.items():
if llm_config and llm_config.for_routing:
llms_for_routing_dict[llm_name] = llm_config
default_agent_config.model_routing.llms_for_routing = (
llms_for_routing_dict
)
logger.openhands_logger.debug(
'Default model routing configuration loaded from config toml and assigned to default agent'
)
except (TypeError, KeyError, ValidationError) as e:
logger.openhands_logger.warning(
f'Cannot parse [model_routing] config from toml, values have not been applied.\nError: {e}'
)
# Process sandbox section if present
if 'sandbox' in toml_config:
try:
@@ -327,6 +357,7 @@ def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None
'condenser',
'mcp',
'kubernetes',
'model_routing',
}
for key in toml_config:
if key.lower() not in known_sections:
@@ -559,6 +590,41 @@ def get_llm_config_arg(
return None
def get_llms_for_routing_config(toml_file: str = 'config.toml') -> dict[str, LLMConfig]:
"""Get the LLMs that are configured for routing from the config file.
This function will return a dictionary of LLMConfig objects that are configured
for routing, i.e., those with `for_routing` set to True.
Args:
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
Returns:
dict[str, LLMConfig]: A dictionary of LLMConfig objects for routing.
"""
llms_for_routing: dict[str, LLMConfig] = {}
try:
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError:
return llms_for_routing
except toml.TomlDecodeError as e:
logger.openhands_logger.error(
f'Cannot parse LLM configs from {toml_file}. Exception: {e}'
)
return llms_for_routing
llm_configs = LLMConfig.from_toml_section(toml_config.get('llm', {}))
if llm_configs:
for llm_name, llm_config in llm_configs.items():
if llm_config.for_routing:
llms_for_routing[llm_name] = llm_config
return llms_for_routing
def get_condenser_config_arg(
condenser_config_arg: str, toml_file: str = 'config.toml'
) -> CondenserConfig | None:
@@ -671,6 +737,50 @@ def get_condenser_config_arg(
return None
def get_model_routing_config_arg(toml_file: str = 'config.toml') -> ModelRoutingConfig:
"""Get the model routing settings from the config file. We only support the default model routing config [model_routing].
Args:
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
Returns:
ModelRoutingConfig: The ModelRoutingConfig object with the settings from the config file, or the object with default values if not found/error.
"""
logger.openhands_logger.debug(
f"Loading model routing config ['model_routing'] from {toml_file}"
)
default_cfg = ModelRoutingConfig()
# load the toml file
try:
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.error(f'Config file not found: {toml_file}. Error: {e}')
return default_cfg
except toml.TomlDecodeError as e:
logger.openhands_logger.error(
f'Cannot parse model routing group [model_routing] from {toml_file}. Exception: {e}'
)
return default_cfg
# Update the model routing config with the specified section
if 'model_routing' in toml_config:
try:
model_routing_data = toml_config['model_routing']
return ModelRoutingConfig(**model_routing_data)
except ValidationError as e:
logger.openhands_logger.error(
f'Invalid model routing configuration for [model_routing]: {e}'
)
return default_cfg
logger.openhands_logger.warning(
f'Model routing config section [model_routing] not found in {toml_file}'
)
return default_cfg
def parse_arguments() -> argparse.Namespace:
"""Parse command line arguments."""
parser = get_headless_parser()
+1 -1
View File
@@ -139,7 +139,7 @@ async def run_controller(
selected_repository=config.sandbox.selected_repo,
repo_directory=repo_directory,
conversation_instructions=conversation_instructions,
working_dir=config.workspace_mount_path_in_sandbox,
working_dir=str(runtime.workspace_root),
)
# Add MCP tools to the agent
+3 -34
View File
@@ -335,26 +335,6 @@ class ProviderHandler:
unique_repos.append(repo)
return unique_repos
def _infer_provider_from_repo_name(self, repo_name: str) -> ProviderType:
"""Infer the git provider from repository name or URL.
Args:
repo_name: Repository name or URL
Returns:
Inferred ProviderType, defaults to GitHub if cannot determine
"""
repo_lower = repo_name.lower()
# Check for provider domains in the repo name/URL
if 'gitlab.com' in repo_lower or 'gitlab' in repo_lower:
return ProviderType.GITLAB
elif 'bitbucket.org' in repo_lower or 'bitbucket' in repo_lower:
return ProviderType.BITBUCKET
else:
# Default to GitHub for unknown or github.com
return ProviderType.GITHUB
async def set_event_stream_secrets(
self,
event_stream: EventStream,
@@ -639,24 +619,13 @@ class ProviderHandler:
Returns:
Authenticated git URL if credentials are available, otherwise regular HTTPS URL
"""
# Initialize variables with defaults
provider = self._infer_provider_from_repo_name(repo_name)
# Keep the original repo_name as provided by default
try:
repository = await self.verify_repo_provider(repo_name)
# Update with verified information if successful
provider = repository.git_provider
repo_name = repository.full_name
except AuthenticationError:
raise Exception('Git provider authentication issue when getting remote URL')
except Exception as e:
# Handle network errors by falling back to public URL
logger.warning(
f'Repository verification failed (possibly offline): {e}. '
f'Using public HTTPS URL for repository: {repo_name}'
)
# Use the inferred provider and original repo_name (already set above)
provider = repository.git_provider
repo_name = repository.full_name
domain = self.PROVIDER_DOMAINS[provider]
+18 -4
View File
@@ -108,10 +108,24 @@ class LLMRegistry:
def get_active_llm(self) -> LLM:
return self.active_agent_llm
def _set_active_llm(self, service_id) -> None:
if service_id not in self.service_to_llm:
raise ValueError(f'Unrecognized service ID: {service_id}')
self.active_agent_llm = self.service_to_llm[service_id]
def get_router(self, agent_config: AgentConfig) -> 'LLM':
"""
Get a router instance that inherits from LLM.
"""
# Import here to avoid circular imports
from openhands.llm.router import RouterLLM
router_name = agent_config.model_routing.router_name
if router_name == 'noop_router':
# Return the main LLM directly (no routing)
return self.get_llm_from_agent_config('agent', agent_config)
return RouterLLM.from_config(
agent_config=agent_config,
llm_registry=self,
retry_listener=self.retry_listner,
)
def subscribe(self, callback: Callable[[RegistryEvent], None]) -> None:
self.subscriber = callback
+1
View File
@@ -122,6 +122,7 @@ SUPPORTS_STOP_WORDS_FALSE_PATTERNS: list[str] = [
'o1*',
# grok-4 specific model name (basename)
'grok-4-0709',
'grok-code-fast-1',
# DeepSeek R1 family
'deepseek-r1-0528*',
]
+39
View File
@@ -0,0 +1,39 @@
# Model Routing Module
**⚠️ Experimental Feature**: This module is experimental and under active development.
## Overview
Model routing enables OpenHands to switch between different LLM models during a conversation. An example use case is routing between a primary (expensive, multimodal) model and a secondary (cheaper, text-only) model.
## Available Routers
- **`noop_router`** (default): No routing, always uses primary LLM
- **`multimodal_router`**: A router that switches based on:
- Routes to primary model for images or when secondary model's context limit is exceeded
- Uses secondary model for text-only requests within its context limit
## Configuration
Add to your `config.toml`:
```toml
# Main LLM (primary model)
[llm]
model = "claude-sonnet-4"
api_key = "your-api-key"
# Secondary model for routing
[llm.secondary_model]
model = "kimi-k2"
api_key = "your-api-key"
for_routing = true
# Enable routing
[model_routing]
router_name = "multimodal_router"
```
## Extending
Create custom routers by inheriting from `BaseRouter` and implementing `set_active_llm()`. Register in `ROUTER_REGISTRY`.
+8
View File
@@ -0,0 +1,8 @@
from openhands.llm.router.base import ROUTER_LLM_REGISTRY, RouterLLM
from openhands.llm.router.rule_based.impl import MultimodalRouter
__all__ = [
'RouterLLM',
'ROUTER_LLM_REGISTRY',
'MultimodalRouter',
]
+164
View File
@@ -0,0 +1,164 @@
import copy
from abc import abstractmethod
from typing import TYPE_CHECKING, Any, Callable
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
if TYPE_CHECKING:
from openhands.llm.llm_registry import LLMRegistry
ROUTER_LLM_REGISTRY: dict[str, type['RouterLLM']] = {}
class RouterLLM(LLM):
"""
Base class for multiple LLM acting as a unified LLM.
This class provides a foundation for implementing model routing by inheriting from LLM,
allowing routers to work with multiple underlying LLM models while presenting a unified
LLM interface to consumers.
Key features:
- Works with multiple LLMs configured via llms_for_routing
- Delegates all other operations/properties to the selected LLM
- Provides routing interface through _select_llm() method
"""
def __init__(
self,
agent_config: AgentConfig,
llm_registry: 'LLMRegistry',
service_id: str = 'router_llm',
metrics: Metrics | None = None,
retry_listener: Callable[[int, int], None] | None = None,
):
"""
Initialize RouterLLM with multiple LLM support.
"""
self.llm_registry = llm_registry
self.model_routing_config = agent_config.model_routing
# Get the primary agent LLM
self.primary_llm = llm_registry.get_llm_from_agent_config('agent', agent_config)
# Instantiate all the LLM instances for routing
llms_for_routing_config = self.model_routing_config.llms_for_routing
self.llms_for_routing = {
config_name: self.llm_registry.get_llm(
f'llm_for_routing.{config_name}', config=llm_config
)
for config_name, llm_config in llms_for_routing_config.items()
}
# All available LLMs for routing (set this BEFORE calling super().__init__)
self.available_llms = {'primary': self.primary_llm, **self.llms_for_routing}
# Create router config based on primary LLM
router_config = copy.deepcopy(self.primary_llm.config)
# Update model name to indicate this is a router
llm_names = [self.primary_llm.config.model]
if self.model_routing_config.llms_for_routing:
llm_names.extend(
config.model
for config in self.model_routing_config.llms_for_routing.values()
)
router_config.model = f'router({",".join(llm_names)})'
# Initialize parent LLM class
super().__init__(
config=router_config,
service_id=service_id,
metrics=metrics,
retry_listener=retry_listener,
)
# Current LLM state
self._current_llm = self.primary_llm # Default to primary LLM
self._last_routing_decision = 'primary'
logger.info(
f'RouterLLM initialized with {len(self.available_llms)} LLMs: {list(self.available_llms.keys())}'
)
@abstractmethod
def _select_llm(self, messages: list[Message]) -> str:
"""
Select which LLM to use based on messages and events.
"""
pass
def _get_llm_by_key(self, llm_key: str) -> LLM:
"""
Get LLM instance by key.
"""
if llm_key not in self.available_llms:
raise ValueError(
f'Unknown LLM key: {llm_key}. Available: {list(self.available_llms.keys())}'
)
return self.available_llms[llm_key]
@property
def completion(self) -> Callable:
"""
Override completion to route to appropriate LLM.
This method intercepts completion calls and routes them to the appropriate
underlying LLM based on the routing logic implemented in _select_llm().
"""
def router_completion(*args: Any, **kwargs: Any) -> Any:
# Extract messages for routing decision
messages = kwargs.get('messages', [])
if args and not messages:
messages = args[0] if args else []
# Select appropriate LLM
selected_llm_key = self._select_llm(messages)
selected_llm = self._get_llm_by_key(selected_llm_key)
# Update current state
self._current_llm = selected_llm
self._last_routing_decision = selected_llm_key
logger.debug(
f'RouterLLM routing to {selected_llm_key} ({selected_llm.config.model})'
)
# Delegate to selected LLM
return selected_llm.completion(*args, **kwargs)
return router_completion
def __str__(self) -> str:
"""String representation of the router."""
return f'{self.__class__.__name__}(llms={list(self.available_llms.keys())})'
def __repr__(self) -> str:
"""Detailed string representation of the router."""
return (
f'{self.__class__.__name__}('
f'primary={self.primary_llm.config.model}, '
f'routing_llms={[llm.config.model for llm in self.llms_for_routing.values()]}, '
f'current={self._last_routing_decision})'
)
def __getattr__(self, name):
"""Delegate other attributes/methods to the active LLM."""
return getattr(self._current_llm, name)
@classmethod
def from_config(
cls, llm_registry: 'LLMRegistry', agent_config: AgentConfig, **kwargs
) -> 'RouterLLM':
"""Factory method to create a RouterLLM instance from configuration."""
router_cls = ROUTER_LLM_REGISTRY.get(agent_config.model_routing.router_name)
if not router_cls:
raise ValueError(
f'Router LLM {agent_config.model_routing.router_name} not found.'
)
return router_cls(agent_config, llm_registry, **kwargs)
+74
View File
@@ -0,0 +1,74 @@
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.llm.llm import LLM
from openhands.llm.llm_registry import LLMRegistry
from openhands.llm.router.base import ROUTER_LLM_REGISTRY, RouterLLM
class MultimodalRouter(RouterLLM):
SECONDARY_MODEL_CONFIG_NAME = 'secondary_model'
ROUTER_NAME = 'multimodal_router'
def __init__(
self,
agent_config: AgentConfig,
llm_registry: LLMRegistry,
**kwargs,
):
super().__init__(agent_config, llm_registry, **kwargs)
self._validate_model_routing_config(self.llms_for_routing)
# States
self.max_token_exceeded = False
def _select_llm(self, messages: list[Message]) -> str:
"""Select LLM based on multimodal content and token limits."""
route_to_primary = False
# Check for multimodal content in messages
for message in messages:
if message.contains_image:
logger.info(
'Multimodal content detected in messages. Routing to the primary model.'
)
route_to_primary = True
if not route_to_primary and self.max_token_exceeded:
route_to_primary = True
# Check if `messages` exceeds context window of the secondary model
# Assuming the secondary model has a lower context window limit compared to the primary model
secondary_llm = self.available_llms.get(self.SECONDARY_MODEL_CONFIG_NAME)
if secondary_llm and (
secondary_llm.config.max_input_tokens
and secondary_llm.get_token_count(messages)
> secondary_llm.config.max_input_tokens
):
logger.warning(
f"Messages having {secondary_llm.get_token_count(messages)} tokens, exceed secondary model's max input tokens ({secondary_llm.config.max_input_tokens} tokens). "
'Routing to the primary model.'
)
self.max_token_exceeded = True
route_to_primary = True
if route_to_primary:
logger.info('Routing to the primary model...')
return 'primary'
else:
logger.info('Routing to the secondary model...')
return self.SECONDARY_MODEL_CONFIG_NAME
def vision_is_active(self):
return self.primary_llm.vision_is_active()
def _validate_model_routing_config(self, llms_for_routing: dict[str, LLM]):
if self.SECONDARY_MODEL_CONFIG_NAME not in llms_for_routing:
raise ValueError(
f'Secondary LLM config {self.SECONDARY_MODEL_CONFIG_NAME} not found.'
)
# Register the router
ROUTER_LLM_REGISTRY[MultimodalRouter.ROUTER_NAME] = MultimodalRouter
+1 -1
View File
@@ -863,7 +863,7 @@ fi
# If the instructions file is not found in the workspace root, try to load it from the repo root
self.log(
'debug',
f'.openhands_instructions not present, trying to load from repository {microagents_dir=}',
f'.openhands_instructions not present, trying to load from repository microagents_dir={microagents_dir}',
)
obs = self.read(
FileReadAction(path=str(repo_root / '.openhands_instructions'))
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik"
```
#### Additional Kubernetes Options
@@ -28,7 +28,6 @@ from openhands.integrations.provider import (
ProviderHandler,
)
from openhands.integrations.service_types import (
AuthenticationError,
CreateMicroagent,
ProviderType,
SuggestedTask,
@@ -244,19 +243,7 @@ async def new_conversation(
if repository:
provider_handler = ProviderHandler(provider_tokens)
# Check against git_provider, otherwise check all provider apis
# Only verify if we have valid provider tokens and can connect
try:
await provider_handler.verify_repo_provider(repository, git_provider)
except AuthenticationError:
# Re-raise authentication errors as they indicate invalid tokens
raise
except Exception as e:
# Log network/connection errors but allow conversation to proceed
# This enables offline usage when no network connectivity is available
logger.warning(
f'Repository verification failed (possibly offline): {e}. '
f'Proceeding with conversation creation for repository: {repository}'
)
await provider_handler.verify_repo_provider(repository, git_provider)
conversation_id = getattr(data, 'conversation_id', None) or uuid.uuid4().hex
agent_loop_info = await create_new_conversation(
@@ -105,7 +105,10 @@ async def start_conversation(
session_init_args = {**settings.__dict__, **session_init_args}
# We could use litellm.check_valid_key for a more accurate check,
# but that would run a tiny inference.
if (
model_name = settings.llm_model or ''
is_bedrock_model = model_name.startswith('bedrock/')
if not is_bedrock_model and (
not settings.llm_api_key
or settings.llm_api_key.get_secret_value().isspace()
):
@@ -113,6 +116,8 @@ async def start_conversation(
raise LLMAuthenticationError(
'Error authenticating with the LLM provider. Please check your API key'
)
elif is_bedrock_model:
logger.info(f'Bedrock model detected ({model_name}), API key not required')
else:
logger.warning('Settings not present, not starting conversation')
+6 -1
View File
@@ -63,9 +63,14 @@ class Settings(BaseModel):
if api_key is None:
return None
# Get the secret value to check if it's empty
secret_value = api_key.get_secret_value()
if not secret_value or not secret_value.strip():
return None
context = info.context
if context and context.get('expose_secrets', False):
return api_key.get_secret_value()
return secret_value
return pydantic_encoder(api_key)
Generated
+25 -7
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -2257,15 +2257,15 @@ files = [
[[package]]
name = "e2b"
version = "1.7.0"
version = "2.0.0"
description = "E2B SDK that give agents cloud environments"
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "extra == \"third-party-runtimes\""
files = [
{file = "e2b-1.7.0-py3-none-any.whl", hash = "sha256:6bd3d935249fcf5684494a97178d4d58446b4ed4018ac09087e4000046e82aab"},
{file = "e2b-1.7.0.tar.gz", hash = "sha256:7783408c2cdf7aee9b088d31759364f2b13b21100cc4e132ba36fd84cfc72e31"},
{file = "e2b-2.0.0-py3-none-any.whl", hash = "sha256:a6621b905cb2a883a9c520736ae98343a6184fc90c29b4f2f079d720294a0df0"},
{file = "e2b-2.0.0.tar.gz", hash = "sha256:4d033d937b0a09b8428e73233321a913cbaef8e7299fc731579c656e9d53a144"},
]
[package.dependencies]
@@ -2273,10 +2273,28 @@ attrs = ">=23.2.0"
httpcore = ">=1.0.5,<2.0.0"
httpx = ">=0.27.0,<1.0.0"
packaging = ">=24.1"
protobuf = ">=5.29.4,<6.0.0"
protobuf = ">=4.21.0"
python-dateutil = ">=2.8.2"
typing-extensions = ">=4.1.0"
[[package]]
name = "e2b-code-interpreter"
version = "2.0.0"
description = "E2B Code Interpreter - Stateful code execution"
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "extra == \"third-party-runtimes\""
files = [
{file = "e2b_code_interpreter-2.0.0-py3-none-any.whl", hash = "sha256:273642d4dd78f09327fb1553fe4f7ddcf17892b78f98236e038d29985e42dca5"},
{file = "e2b_code_interpreter-2.0.0.tar.gz", hash = "sha256:19136916be8de60bfd0a678742501d1d0335442bb6e86405c7dd6f98059b73c4"},
]
[package.dependencies]
attrs = ">=21.3.0"
e2b = ">=2.0.0,<3.0.0"
httpx = ">=0.20.0,<1.0.0"
[[package]]
name = "english-words"
version = "2.0.1"
@@ -11845,9 +11863,9 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\
cffi = ["cffi (>=1.11)"]
[extras]
third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "a0ae2cee596dde71f89c06e9669efda58ee8f8f019fad3dbe9df068005c32904"
content-hash = "5135db5c5c744f7b2aab0ccb6921343d2268d8ef950e024ddc3bce25c597140a"
+3 -3
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.55.0"
version = "0.56.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -96,14 +96,14 @@ memory-profiler = "^0.61.0"
jupyter_kernel_gateway = "*"
# Third-party runtime dependencies (optional)
e2b = { version = ">=1.0.5,<1.8.0", optional = true }
modal = { version = ">=0.66.26,<1.2.0", optional = true }
runloop-api-client = { version = "0.50.0", optional = true }
daytona = { version = "0.24.2", optional = true }
httpx-aiohttp = "^0.1.8"
e2b-code-interpreter = { version = "^2.0.0", optional = true }
[tool.poetry.extras]
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
third_party_runtimes = [ "e2b-code-interpreter", "modal", "runloop-api-client", "daytona" ]
[tool.poetry.group.dev]
optional = true
+4
View File
@@ -443,6 +443,8 @@ def test_sandbox_volumes(monkeypatch, default_config):
'SANDBOX_VOLUMES',
'/host/path1:/container/path1,/host/path2:/container/path2:ro',
)
# Clear any existing workspace mount path to test default behavior
monkeypatch.delenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', raising=False)
load_from_env(default_config, os.environ)
finalize_config(default_config)
@@ -465,6 +467,8 @@ def test_sandbox_volumes(monkeypatch, default_config):
def test_sandbox_volumes_with_mode(monkeypatch, default_config):
# Test SANDBOX_VOLUMES with read-only mode (no explicit /workspace mount)
monkeypatch.setenv('SANDBOX_VOLUMES', '/host/path1:/container/path1:ro')
# Clear any existing workspace mount path to test default behavior
monkeypatch.delenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', raising=False)
load_from_env(default_config, os.environ)
finalize_config(default_config)
@@ -1,121 +0,0 @@
"""Tests for provider offline functionality and variable scope issues."""
from types import MappingProxyType
from unittest.mock import AsyncMock, patch
import pytest
from openhands.integrations.provider import ProviderHandler, ProviderToken, ProviderType
from openhands.integrations.service_types import AuthenticationError
class TestProviderOfflineFunctionality:
"""Test offline functionality and variable scope in ProviderHandler."""
@pytest.fixture
def provider_handler(self):
"""Create a ProviderHandler instance for testing."""
tokens = MappingProxyType(
{
ProviderType.GITHUB: ProviderToken(token='test_token'),
ProviderType.GITLAB: ProviderToken(token='gitlab_token'),
}
)
return ProviderHandler(provider_tokens=tokens)
@pytest.mark.asyncio
async def test_get_authenticated_git_url_network_error_handling(
self, provider_handler
):
"""Test that network errors are properly handled with fallback to inferred provider.
After the fix, variables are properly initialized before the try block,
ensuring they're always available regardless of which exception path is taken.
"""
repo_name = 'test-owner/test-repo'
# Mock verify_repo_provider to raise a non-AuthenticationError exception
# This simulates a network error or other exception during offline operation
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
# Simulate a network error (not AuthenticationError)
mock_verify.side_effect = ConnectionError('Network unreachable')
# After the fix, this should work correctly with proper variable initialization
result = await provider_handler.get_authenticated_git_url(repo_name)
# Should return a GitHub URL with token (inferred from repo name)
assert result == 'https://test_token@github.com/test-owner/test-repo.git'
@pytest.mark.asyncio
async def test_get_authenticated_git_url_proper_variable_scope(
self, provider_handler
):
"""Test that verifies the variables are properly scoped after the fix.
This test ensures that after fixing the code structure, the variables
'provider' and 'repo_name' are properly initialized and available
regardless of which exception path is taken.
"""
repo_name = 'test-owner/test-repo'
# Test with network error - should use inferred provider and original repo_name
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
mock_verify.side_effect = ConnectionError('Network unreachable')
result = await provider_handler.get_authenticated_git_url(repo_name)
# Should return authenticated URL with inferred GitHub provider
assert result == 'https://test_token@github.com/test-owner/test-repo.git'
# Test with successful verification - should use verified provider and repo_name
mock_repository = AsyncMock()
mock_repository.git_provider = ProviderType.GITLAB
mock_repository.full_name = 'verified-owner/verified-repo'
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
mock_verify.return_value = mock_repository
result = await provider_handler.get_authenticated_git_url(repo_name)
# Should return authenticated GitLab URL with verified details
assert (
result
== 'https://oauth2:gitlab_token@gitlab.com/verified-owner/verified-repo.git'
)
@pytest.mark.asyncio
async def test_get_authenticated_git_url_auth_error_handling(
self, provider_handler
):
"""Test that AuthenticationError is properly handled and re-raised."""
repo_name = 'test-owner/test-repo'
# Mock verify_repo_provider to raise AuthenticationError
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
mock_verify.side_effect = AuthenticationError('Invalid token')
# AuthenticationError should be re-raised as a generic Exception
with pytest.raises(Exception) as exc_info:
await provider_handler.get_authenticated_git_url(repo_name)
assert 'Git provider authentication issue when getting remote URL' in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_get_authenticated_git_url_successful_case(self, provider_handler):
"""Test the successful case where repository verification works."""
repo_name = 'test-owner/test-repo'
# Mock a successful repository verification
mock_repository = AsyncMock()
mock_repository.git_provider = ProviderType.GITHUB
mock_repository.full_name = 'test-owner/test-repo'
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
mock_verify.return_value = mock_repository
result = await provider_handler.get_authenticated_git_url(repo_name)
# Should return an authenticated GitHub URL
assert result == 'https://test_token@github.com/test-owner/test-repo.git'
+3
View File
@@ -286,6 +286,8 @@ def test_prompt_cache_haiku_variants():
def test_stop_words_grok_provider_prefixed():
assert get_features('xai/grok-4-0709').supports_stop_words is False
assert get_features('grok-4-0709').supports_stop_words is False
assert get_features('xai/grok-code-fast-1').supports_stop_words is False
assert get_features('grok-code-fast-1').supports_stop_words is False
@pytest.mark.parametrize(
@@ -294,6 +296,7 @@ def test_stop_words_grok_provider_prefixed():
'o1-mini',
'o1-2024-12-17',
'xai/grok-4-0709',
'xai/grok-code-fast-1',
'deepseek/DeepSeek-R1-0528:671b-Q4_K_XL',
'DeepSeek-R1-0528',
],
@@ -1,159 +0,0 @@
"""Test offline conversation creation functionality."""
def test_offline_repository_verification_logic():
"""Test the logic for handling offline repository verification.
This test validates that our fix correctly handles different exception types:
- AuthenticationError should be re-raised (invalid tokens)
- Other exceptions should be logged and ignored (network issues)
"""
# Define a mock AuthenticationError for testing
class AuthenticationError(Exception):
pass
# Test case 1: AuthenticationError should be re-raised
def test_auth_error_handling():
"""Simulate the exception handling logic in our fix."""
try:
# Simulate AuthenticationError from repository verification
raise AuthenticationError('Invalid token')
except AuthenticationError:
# This should be re-raised
return 'auth_error_reraised'
except Exception:
# This should not be reached for AuthenticationError
return 'other_error_ignored'
# Test case 2: Network errors should be ignored
def test_network_error_handling():
"""Simulate the exception handling logic in our fix."""
try:
# Simulate network error from repository verification
raise Exception('Network unreachable')
except Exception as e:
# Check if it's an AuthenticationError
if isinstance(e, AuthenticationError):
return 'auth_error_reraised'
else:
# Log and ignore other errors (network issues)
return 'network_error_ignored'
# Run the tests
assert test_auth_error_handling() == 'auth_error_reraised'
assert test_network_error_handling() == 'network_error_ignored'
def test_repository_verification_skip_logic():
"""Test that repository verification can be skipped when appropriate."""
# Define a mock AuthenticationError for testing
class AuthenticationError(Exception):
pass
def simulate_conversation_creation_with_repo(
repository, has_network_error=False, has_auth_error=False
):
"""Simulate the conversation creation logic with our fix."""
if repository:
# Simulate provider handler creation
# provider_handler = ProviderHandler(provider_tokens)
try:
# Simulate repository verification
if has_auth_error:
raise AuthenticationError('Invalid token')
elif has_network_error:
raise Exception('Network unreachable')
else:
# Successful verification
pass
except Exception as e:
if isinstance(e, AuthenticationError):
# Re-raise authentication errors
raise
else:
# Log and ignore network errors
print(
f'Repository verification failed (possibly offline): {e}. Proceeding with conversation creation.'
)
# Continue with conversation creation
return 'conversation_created'
# Test successful verification
result = simulate_conversation_creation_with_repo(
'test/repo', has_network_error=False, has_auth_error=False
)
assert result == 'conversation_created'
# Test network error (should proceed)
result = simulate_conversation_creation_with_repo(
'test/repo', has_network_error=True, has_auth_error=False
)
assert result == 'conversation_created'
# Test authentication error (should raise)
try:
simulate_conversation_creation_with_repo(
'test/repo', has_network_error=False, has_auth_error=True
)
raise AssertionError('Should have raised AuthenticationError')
except AuthenticationError:
pass # Expected
# Test no repository (should proceed)
result = simulate_conversation_creation_with_repo(None)
assert result == 'conversation_created'
def test_provider_inference_logic():
"""Test the provider inference logic for offline scenarios."""
# Mock the ProviderType enum
class ProviderType:
GITHUB = 'github'
GITLAB = 'gitlab'
BITBUCKET = 'bitbucket'
def infer_provider_from_repo_name(repo_name: str):
"""Simulate the provider inference logic."""
repo_lower = repo_name.lower()
# Check for provider domains in the repo name/URL
if 'gitlab.com' in repo_lower or 'gitlab' in repo_lower:
return ProviderType.GITLAB
elif 'bitbucket.org' in repo_lower or 'bitbucket' in repo_lower:
return ProviderType.BITBUCKET
else:
# Default to GitHub for unknown or github.com
return ProviderType.GITHUB
# Test various repository name formats
assert infer_provider_from_repo_name('owner/repo') == ProviderType.GITHUB
assert (
infer_provider_from_repo_name('https://github.com/owner/repo')
== ProviderType.GITHUB
)
assert (
infer_provider_from_repo_name('https://gitlab.com/owner/repo')
== ProviderType.GITLAB
)
assert (
infer_provider_from_repo_name('https://bitbucket.org/owner/repo')
== ProviderType.BITBUCKET
)
assert infer_provider_from_repo_name('gitlab-owner/repo') == ProviderType.GITLAB
assert (
infer_provider_from_repo_name('bitbucket-owner/repo') == ProviderType.BITBUCKET
)
if __name__ == '__main__':
test_offline_repository_verification_logic()
test_repository_verification_skip_logic()
test_provider_inference_logic()
print(
'✅ All tests passed! Offline conversation creation logic is working correctly.'
)
@@ -27,6 +27,7 @@ class FakeEventStream:
class FakeRuntime:
def __init__(self):
self.event_stream = FakeEventStream()
self.workspace_root = '/workspace'
async def connect(self):
return None
+358 -34
View File
@@ -1,32 +1,50 @@
import os
from typing import Callable
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
BrowseURLAction,
BrowseInteractiveAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
)
from openhands.events.observation import (
BrowserOutputObservation,
CmdOutputObservation,
ErrorObservation,
FileEditObservation,
FileReadObservation,
FileWriteObservation,
IPythonRunCellObservation,
Observation,
)
from openhands.events.stream import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from third_party.runtime.impl.e2b.filestore import E2BFileStore
from third_party.runtime.impl.e2b.sandbox import E2BSandbox
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.utils.async_utils import call_sync_from_async
from third_party.runtime.impl.e2b.filestore import E2BFileStore
from third_party.runtime.impl.e2b.sandbox import E2BBox, E2BSandbox
class E2BRuntime(ActionExecutionClient):
# Class-level cache for sandbox IDs
_sandbox_id_cache: dict[str, str] = {}
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = "default",
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
@@ -37,42 +55,348 @@ class E2BRuntime(ActionExecutionClient):
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
sandbox: E2BSandbox | None = None,
):
if config.workspace_base is not None:
logger.warning(
"Setting workspace_base is not supported in the E2B runtime. "
"E2B provides its own isolated filesystem."
)
super().__init__(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
config=config,
event_stream=event_stream,
llm_registry=llm_registry,
sid=sid,
plugins=plugins,
env_vars=env_vars,
status_callback=status_callback,
attach_to_existing=attach_to_existing,
headless_mode=headless_mode,
user_id=user_id,
git_provider_tokens=git_provider_tokens,
)
if sandbox is None:
self.sandbox = E2BSandbox(config.sandbox)
if not isinstance(self.sandbox, E2BSandbox):
raise ValueError("E2BRuntime requires an E2BSandbox")
self.file_store = E2BFileStore(self.sandbox.filesystem)
self.sandbox = sandbox
self.file_store = None
self.api_url = None
self._action_server_port = 8000
self._runtime_initialized = False
@property
def action_execution_server_url(self) -> str:
"""Return the URL of the action execution server."""
if not self.api_url:
raise RuntimeError("E2B runtime not connected. Call connect() first.")
return self.api_url
async def connect(self) -> None:
"""Initialize E2B sandbox and start action execution server."""
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
try:
if self.attach_to_existing and self.sandbox is None:
try:
cached_sandbox_id = self.__class__._sandbox_id_cache.get(self.sid)
if cached_sandbox_id:
try:
self.sandbox = E2BBox(self.config.sandbox, sandbox_id=cached_sandbox_id)
logger.info(f"Successfully attached to existing E2B sandbox: {cached_sandbox_id}")
except Exception as e:
logger.warning(f"Failed to connect to cached sandbox {cached_sandbox_id}: {e}")
del self.__class__._sandbox_id_cache[self.sid]
self.sandbox = None
except Exception as e:
logger.warning(f"Failed to attach to existing sandbox: {e}. Will create a new one.")
# Create E2B sandbox if not provided
if self.sandbox is None:
try:
self.sandbox = E2BSandbox(self.config.sandbox)
sandbox_id = self.sandbox.sandbox.sandbox_id
logger.info(f"E2B sandbox created with ID: {sandbox_id}")
self.__class__._sandbox_id_cache[self.sid] = sandbox_id
except Exception as e:
logger.error(f"Failed to create E2B sandbox: {e}")
raise
if not isinstance(self.sandbox, (E2BSandbox, E2BBox)):
raise ValueError("E2BRuntime requires an E2BSandbox or E2BBox")
self.file_store = E2BFileStore(self.sandbox.filesystem)
# E2B doesn't use action execution server - set dummy URL
self.api_url = "direct://e2b-sandbox"
workspace_dir = self.config.workspace_mount_path_in_sandbox
if workspace_dir:
try:
exit_code, output = self.sandbox.execute(f"sudo mkdir -p {workspace_dir}")
if exit_code == 0:
self.sandbox.execute(f"sudo chmod 777 {workspace_dir}")
logger.info(f"Created workspace directory: {workspace_dir}")
else:
logger.warning(f"Failed to create workspace directory: {output}")
except Exception as e:
logger.warning(f"Failed to create workspace directory: {e}")
await call_sync_from_async(self.setup_initial_env)
self._runtime_initialized = True
self.set_runtime_status(RuntimeStatus.READY)
logger.info("E2B runtime connected successfully")
except Exception as e:
logger.error(f"Failed to connect E2B runtime: {e}")
self.set_runtime_status(RuntimeStatus.FAILED)
raise
async def close(self) -> None:
"""Close the E2B runtime."""
if self._runtime_closed:
return
self._runtime_closed = True
if self.sandbox:
try:
if not self.attach_to_existing:
self.sandbox.close()
if self.sid in self.__class__._sandbox_id_cache:
del self.__class__._sandbox_id_cache[self.sid]
logger.info("E2B sandbox closed and removed from cache")
else:
logger.info("E2B runtime connection closed, sandbox kept running for reuse")
except Exception as e:
logger.error(f"Error closing E2B sandbox: {e}")
parent_close = super().close()
if parent_close is not None:
await parent_close
def run(self, action: CmdRunAction) -> Observation:
"""Execute command using E2B's native execute method."""
if self.sandbox is None:
return ErrorObservation("E2B sandbox not initialized")
try:
timeout = action.timeout if action.timeout else self.config.sandbox.timeout
exit_code, output = self.sandbox.execute(action.command, timeout=timeout)
return CmdOutputObservation(
content=output,
command=action.command,
exit_code=exit_code
)
except Exception as e:
return ErrorObservation(f"Failed to execute command: {e}")
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
"""Execute IPython code using E2B's code interpreter."""
if self.sandbox is None:
return ErrorObservation("E2B sandbox not initialized")
try:
result = self.sandbox.sandbox.run_code(action.code)
outputs = []
if hasattr(result, 'results') and result.results:
for r in result.results:
if hasattr(r, 'text') and r.text:
outputs.append(r.text)
elif hasattr(r, 'html') and r.html:
outputs.append(r.html)
elif hasattr(r, 'png') and r.png:
outputs.append(f"[Image data: {len(r.png)} bytes]")
if hasattr(result, 'error') and result.error:
return ErrorObservation(f"IPython error: {result.error}")
return IPythonRunCellObservation(
content='\n'.join(outputs) if outputs else '',
code=action.code
)
except Exception as e:
return ErrorObservation(f"Failed to execute IPython code: {e}")
def read(self, action: FileReadAction) -> Observation:
content = self.file_store.read(action.path)
lines = read_lines(content.split("\n"), action.start, action.end)
code_view = "".join(lines)
return FileReadObservation(code_view, path=action.path)
if self.file_store is None:
return ErrorObservation("E2B file store not initialized. Call connect() first.")
try:
content = self.file_store.read(action.path)
lines = read_lines(content.split("\n"), action.start, action.end)
code_view = "".join(lines)
return FileReadObservation(code_view, path=action.path)
except Exception as e:
return ErrorObservation(f"Failed to read file: {e}")
def write(self, action: FileWriteAction) -> Observation:
if action.start == 0 and action.end == -1:
self.file_store.write(action.path, action.content)
return FileWriteObservation(content="", path=action.path)
files = self.file_store.list(action.path)
if action.path in files:
all_lines = self.file_store.read(action.path).split("\n")
new_file = insert_lines(
action.content.split("\n"), all_lines, action.start, action.end
if self.file_store is None:
return ErrorObservation("E2B file store not initialized. Call connect() first.")
try:
if action.start == 0 and action.end == -1:
self.file_store.write(action.path, action.content)
return FileWriteObservation(content="", path=action.path)
files = self.file_store.list(action.path)
if action.path in files:
all_lines = self.file_store.read(action.path).split("\n")
new_file = insert_lines(
action.content.split("\n"), all_lines, action.start, action.end
)
self.file_store.write(action.path, "".join(new_file))
return FileWriteObservation("", path=action.path)
else:
# Create a new file
self.file_store.write(action.path, action.content)
return FileWriteObservation(content="", path=action.path)
except Exception as e:
return ErrorObservation(f"Failed to write file: {e}")
def edit(self, action: FileEditAction) -> Observation:
"""Edit a file using E2B's file system."""
if self.file_store is None:
return ErrorObservation("E2B file store not initialized. Call connect() first.")
try:
if action.path in self.file_store.list(action.path):
content = self.file_store.read(action.path)
else:
return ErrorObservation(f"File {action.path} not found")
lines = content.split('\n')
if action.start < 0 or action.end > len(lines):
return ErrorObservation(f"Invalid line range: {action.start}-{action.end}")
new_lines = lines[:action.start] + action.content.split('\n') + lines[action.end:]
new_content = '\n'.join(new_lines)
self.file_store.write(action.path, new_content)
return FileEditObservation(
content='',
path=action.path,
old_content='\n'.join(lines[action.start:action.end]),
start=action.start,
end=action.end
)
self.file_store.write(action.path, "".join(new_file))
return FileWriteObservation("", path=action.path)
else:
# FIXME: we should create a new file here
return ErrorObservation(f"File not found: {action.path}")
except Exception as e:
return ErrorObservation(f"Failed to edit file: {e}")
def browse(self, action: BrowseURLAction) -> Observation:
"""Browse a URL using E2B's browser capabilities."""
if self.sandbox is None:
return ErrorObservation("E2B sandbox not initialized")
try:
exit_code, output = self.sandbox.execute(f"curl -s -L '{action.url}'")
if exit_code != 0:
exit_code, output = self.sandbox.execute(f"wget -qO- '{action.url}'")
if exit_code != 0:
return ErrorObservation(f"Failed to fetch URL: {output}")
return BrowserOutputObservation(
content=output,
url=action.url,
screenshot=None,
error=None if exit_code == 0 else output
)
except Exception as e:
return ErrorObservation(f"Failed to browse URL: {e}")
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
"""Interactive browsing is not supported in E2B."""
return ErrorObservation(
"Interactive browsing is not supported in E2B runtime. "
"Use browse() for simple URL fetching or consider using a different runtime."
)
def list_files(self, path: str | None = None) -> list[str]:
"""List files in the sandbox."""
if self.sandbox is None:
logger.warning("Cannot list files: E2B sandbox not initialized")
return []
if path is None:
path = self.config.workspace_mount_path_in_sandbox or '/workspace'
try:
exit_code, output = self.sandbox.execute(f"find {path} -maxdepth 1 -type f -o -type d")
if exit_code == 0:
files = [line.strip() for line in output.strip().split('\n') if line.strip()]
return [f.replace(path + '/', '') if f.startswith(path + '/') else f for f in files]
else:
logger.warning(f"Failed to list files in {path}: {output}")
return []
except Exception as e:
logger.warning(f"Error listing files: {e}")
return []
def add_env_vars(self, env_vars: dict[str, str]) -> None:
"""Add environment variables to the E2B sandbox."""
if self.sandbox is None:
logger.warning("Cannot add env vars: E2B sandbox not initialized")
return
if not hasattr(self, '_env_vars'):
self._env_vars = {}
self._env_vars.update(env_vars)
for key, value in env_vars.items():
try:
escaped_value = value.replace("'", "'\"'\"'")
cmd = f"export {key}='{escaped_value}'"
self.sandbox.execute(cmd)
logger.debug(f"Set env var: {key}")
except Exception as e:
logger.warning(f"Failed to set env var {key}: {e}")
def get_working_directory(self) -> str:
"""Get the current working directory."""
if self.sandbox is None:
return self.config.workspace_mount_path_in_sandbox or '/workspace'
try:
exit_code, output = self.sandbox.execute("pwd")
if exit_code == 0:
return output.strip()
except Exception:
pass
return self.config.workspace_mount_path_in_sandbox or '/workspace'
def get_mcp_config(self, extra_stdio_servers: list | None = None) -> dict:
"""Get MCP configuration for E2B runtime."""
return {
'stdio_servers': extra_stdio_servers or []
}
def check_if_alive(self) -> None:
"""Check if the E2B sandbox is alive."""
if self.sandbox is None:
raise RuntimeError("E2B sandbox not initialized")
return
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False) -> None:
"""Copy files to the E2B sandbox."""
if self.sandbox is None:
raise RuntimeError("E2B sandbox not initialized")
self.sandbox.copy_to(host_src, sandbox_dest, recursive)
def get_vscode_token(self) -> str:
"""E2B doesn't support VSCode integration."""
return ""
@classmethod
def setup(cls, config: OpenHandsConfig, headless_mode: bool = False) -> None:
"""Set up the E2B runtime environment."""
logger.info("E2B runtime setup called")
pass
@classmethod
def teardown(cls, config: OpenHandsConfig) -> None:
"""Tear down the E2B runtime environment."""
logger.info("E2B runtime teardown called")
pass
+58 -31
View File
@@ -3,7 +3,7 @@ import os
import tarfile
from glob import glob
from e2b import Sandbox as E2BSandbox
from e2b_code_interpreter import Sandbox
from e2b.exceptions import TimeoutException
from openhands.core.config import SandboxConfig
@@ -19,7 +19,7 @@ class E2BBox:
def __init__(
self,
config: SandboxConfig,
template: str = "openhands",
sandbox_id: str | None = None,
):
self.config = copy.deepcopy(config)
self.initialize_plugins: bool = config.initialize_plugins
@@ -30,20 +30,41 @@ class E2BBox:
raise ValueError(
"E2B_API_KEY environment variable is required for E2B runtime"
)
# Read custom E2B domain if provided
e2b_domain = os.getenv("E2B_DOMAIN")
if e2b_domain:
logger.info(f'Using custom E2B domain: {e2b_domain}')
self.sandbox = E2BSandbox(
api_key=e2b_api_key,
template=template,
# It's possible to stream stdout and stderr from sandbox and from each process
on_stderr=lambda x: logger.debug(f"E2B sandbox stderr: {x}"),
on_stdout=lambda x: logger.debug(f"E2B sandbox stdout: {x}"),
cwd=self._cwd, # Default workdir inside sandbox
)
logger.debug(f'Started E2B sandbox with ID "{self.sandbox.id}"')
# E2B v2 requires using create() method or connect to existing
try:
# Configure E2B client with custom domain if provided
create_kwargs = {}
connect_kwargs = {}
if e2b_domain:
# Set up custom domain configuration
# Note: This depends on E2B SDK version and may need adjustment
os.environ['E2B_API_URL'] = f'https://{e2b_domain}'
logger.info(f'Set E2B_API_URL to https://{e2b_domain}')
if sandbox_id:
# Connect to existing sandbox
self.sandbox = Sandbox.connect(sandbox_id, **connect_kwargs)
logger.info(f'Connected to existing E2B sandbox with ID "{sandbox_id}"')
else:
# Create new sandbox (e2b-code-interpreter doesn't need template)
self.sandbox = Sandbox.create(**create_kwargs)
sandbox_id = self.sandbox.sandbox_id
logger.info(f'Created E2B sandbox with ID "{sandbox_id}"')
except Exception as e:
logger.error(f"Failed to create/connect E2B sandbox: {e}")
raise
@property
def filesystem(self):
return self.sandbox.filesystem
# E2B v2 uses 'files' instead of 'filesystem'
return getattr(self.sandbox, 'files', None) or getattr(self.sandbox, 'filesystem', None)
def _archive(self, host_src: str, recursive: bool = False):
if recursive:
@@ -70,21 +91,23 @@ class E2BBox:
def execute(self, cmd: str, timeout: int | None = None) -> tuple[int, str]:
timeout = timeout if timeout is not None else self.config.timeout
process = self.sandbox.process.start(cmd, env_vars=self._env)
# E2B code-interpreter uses commands.run()
try:
process_output = process.wait(timeout=timeout)
result = self.sandbox.commands.run(cmd)
output = ""
if hasattr(result, 'stdout') and result.stdout:
output += result.stdout
if hasattr(result, 'stderr') and result.stderr:
output += result.stderr
exit_code = getattr(result, 'exit_code', 0) or 0
return exit_code, output
except TimeoutException:
logger.debug("Command timed out, killing process...")
process.kill()
logger.debug("Command timed out")
return -1, f'Command: "{cmd}" timed out'
logs = [m.line for m in process_output.messages]
logs_str = "\n".join(logs)
if process.exit_code is None:
return -1, logs_str
assert process_output.exit_code is not None
return process_output.exit_code, logs_str
except Exception as e:
logger.error(f"Command execution failed: {e}")
return -1, str(e)
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
"""Copies a local file or directory to the sandbox."""
@@ -98,24 +121,28 @@ class E2BBox:
uploaded_path = self.sandbox.upload_file(tar_file)
# Check if sandbox_dest exists. If not, create it.
process = self.sandbox.process.start_and_wait(f"test -d {sandbox_dest}")
if process.exit_code != 0:
self.sandbox.filesystem.make_dir(sandbox_dest)
exit_code, _ = self.execute(f"test -d {sandbox_dest}")
if exit_code != 0:
self.execute(f"mkdir -p {sandbox_dest}")
# Extract the archive into the destination and delete the archive
process = self.sandbox.process.start_and_wait(
exit_code, output = self.execute(
f"sudo tar -xf {uploaded_path} -C {sandbox_dest} && sudo rm {uploaded_path}"
)
if process.exit_code != 0:
if exit_code != 0:
raise Exception(
f"Failed to extract {uploaded_path} to {sandbox_dest}: {process.stderr}"
f"Failed to extract {uploaded_path} to {sandbox_dest}: {output}"
)
# Delete the local archive
os.remove(tar_filename)
def close(self):
self.sandbox.close()
# E2B v2 uses kill() instead of close()
if hasattr(self.sandbox, 'kill'):
self.sandbox.kill()
elif hasattr(self.sandbox, 'close'):
self.sandbox.close()
def get_working_directory(self):
return self.sandbox.cwd