mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f73ee91d | |||
| a25826a5f9 | |||
| df9320f8ab | |||
| af0ab5a9f2 | |||
| 9960d11d08 | |||
| d5d5e265f8 |
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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` を実行してください。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*',
|
||||
]
|
||||
|
||||
@@ -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`.
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user