Feat sandbox skills (#11785)

This commit is contained in:
Tim O'Farrell
2025-11-20 10:52:13 +00:00
committed by GitHub
parent 77b565ce08
commit ba883ffeca
10 changed files with 81 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest, AppConversationStartRequest,
AppConversationStartTask, AppConversationStartTask,
) )
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.services.injector import Injector from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin from openhands.sdk.utils.models import DiscriminatedUnionMixin
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
@@ -91,6 +92,7 @@ class AppConversationService(ABC):
async def run_setup_scripts( async def run_setup_scripts(
self, self,
task: AppConversationStartTask, task: AppConversationStartTask,
sandbox: SandboxInfo,
workspace: AsyncRemoteWorkspace, workspace: AsyncRemoteWorkspace,
) -> AsyncGenerator[AppConversationStartTask, None]: ) -> AsyncGenerator[AppConversationStartTask, None]:
"""Run the setup scripts for the project and yield status updates""" """Run the setup scripts for the project and yield status updates"""

View File

@@ -18,9 +18,12 @@ from openhands.app_server.app_conversation.app_conversation_service import (
from openhands.app_server.app_conversation.skill_loader import ( from openhands.app_server.app_conversation.skill_loader import (
load_global_skills, load_global_skills,
load_repo_skills, load_repo_skills,
load_sandbox_skills,
merge_skills, merge_skills,
) )
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.user.user_context import UserContext from openhands.app_server.user.user_context import UserContext
from openhands.sdk import Agent
from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.agent_context import AgentContext
from openhands.sdk.context.skills import load_user_skills from openhands.sdk.context.skills import load_user_skills
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
@@ -41,6 +44,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
async def _load_and_merge_all_skills( async def _load_and_merge_all_skills(
self, self,
sandbox: SandboxInfo,
remote_workspace: AsyncRemoteWorkspace, remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None, selected_repository: str | None,
working_dir: str, working_dir: str,
@@ -62,6 +66,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
_logger.debug('Loading skills for V1 conversation') _logger.debug('Loading skills for V1 conversation')
# Load skills from all sources # Load skills from all sources
sandbox_skills = load_sandbox_skills(sandbox)
global_skills = load_global_skills() global_skills = load_global_skills()
# Load user skills from ~/.openhands/skills/ directory # Load user skills from ~/.openhands/skills/ directory
# Uses the SDK's load_user_skills() function which handles loading from # Uses the SDK's load_user_skills() function which handles loading from
@@ -79,7 +84,9 @@ class AppConversationServiceBase(AppConversationService, ABC):
) )
# Merge all skills (later lists override earlier ones) # Merge all skills (later lists override earlier ones)
all_skills = merge_skills([global_skills, user_skills, repo_skills]) all_skills = merge_skills(
[sandbox_skills, global_skills, user_skills, repo_skills]
)
_logger.info( _logger.info(
f'Loaded {len(all_skills)} total skills: {[s.name for s in all_skills]}' f'Loaded {len(all_skills)} total skills: {[s.name for s in all_skills]}'
@@ -121,7 +128,8 @@ class AppConversationServiceBase(AppConversationService, ABC):
async def _load_skills_and_update_agent( async def _load_skills_and_update_agent(
self, self,
agent, sandbox: SandboxInfo,
agent: Agent,
remote_workspace: AsyncRemoteWorkspace, remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None, selected_repository: str | None,
working_dir: str, working_dir: str,
@@ -139,7 +147,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
""" """
# Load and merge all skills # Load and merge all skills
all_skills = await self._load_and_merge_all_skills( all_skills = await self._load_and_merge_all_skills(
remote_workspace, selected_repository, working_dir sandbox, remote_workspace, selected_repository, working_dir
) )
# Update agent with skills # Update agent with skills
@@ -150,6 +158,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
async def run_setup_scripts( async def run_setup_scripts(
self, self,
task: AppConversationStartTask, task: AppConversationStartTask,
sandbox: SandboxInfo,
workspace: AsyncRemoteWorkspace, workspace: AsyncRemoteWorkspace,
) -> AsyncGenerator[AppConversationStartTask, None]: ) -> AsyncGenerator[AppConversationStartTask, None]:
task.status = AppConversationStartTaskStatus.PREPARING_REPOSITORY task.status = AppConversationStartTaskStatus.PREPARING_REPOSITORY
@@ -167,6 +176,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task yield task
await self._load_and_merge_all_skills( await self._load_and_merge_all_skills(
sandbox,
workspace, workspace,
task.request.selected_repository, task.request.selected_repository,
workspace.working_dir, workspace.working_dir,

View File

@@ -218,12 +218,15 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
api_key=sandbox.session_api_key, api_key=sandbox.session_api_key,
working_dir=sandbox_spec.working_dir, working_dir=sandbox_spec.working_dir,
) )
async for updated_task in self.run_setup_scripts(task, remote_workspace): async for updated_task in self.run_setup_scripts(
task, sandbox, remote_workspace
):
yield updated_task yield updated_task
# Build the start request # Build the start request
start_conversation_request = ( start_conversation_request = (
await self._build_start_conversation_request_for_user( await self._build_start_conversation_request_for_user(
sandbox,
request.initial_message, request.initial_message,
request.git_provider, request.git_provider,
sandbox_spec.working_dir, sandbox_spec.working_dir,
@@ -512,6 +515,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
async def _build_start_conversation_request_for_user( async def _build_start_conversation_request_for_user(
self, self,
sandbox: SandboxInfo,
initial_message: SendMessageRequest | None, initial_message: SendMessageRequest | None,
git_provider: ProviderType | None, git_provider: ProviderType | None,
working_dir: str, working_dir: str,
@@ -558,6 +562,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
api_key=user.llm_api_key, api_key=user.llm_api_key,
usage_id='agent', usage_id='agent',
) )
# The agent gets passed initial instructions
# Select agent based on agent_type # Select agent based on agent_type
if agent_type == AgentType.PLAN: if agent_type == AgentType.PLAN:
agent = get_planning_agent(llm=llm) agent = get_planning_agent(llm=llm)
@@ -573,7 +578,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if remote_workspace: if remote_workspace:
try: try:
agent = await self._load_skills_and_update_agent( agent = await self._load_skills_and_update_agent(
agent, remote_workspace, selected_repository, working_dir sandbox, agent, remote_workspace, selected_repository, working_dir
) )
except Exception as e: except Exception as e:
_logger.warning(f'Failed to load skills: {e}', exc_info=True) _logger.warning(f'Failed to load skills: {e}', exc_info=True)

View File

@@ -13,6 +13,7 @@ import os
from pathlib import Path from pathlib import Path
import openhands import openhands
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.sdk.context.skills import Skill from openhands.sdk.context.skills import Skill
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
@@ -23,6 +24,8 @@ GLOBAL_SKILLS_DIR = os.path.join(
os.path.dirname(os.path.dirname(openhands.__file__)), os.path.dirname(os.path.dirname(openhands.__file__)),
'skills', 'skills',
) )
WORK_HOSTS_SKILL = """The user has access to the following hosts for accessing a web application,
each of which has a corresponding port:"""
def _find_and_load_global_skill_files(skill_dir: Path) -> list[Skill]: def _find_and_load_global_skill_files(skill_dir: Path) -> list[Skill]:
@@ -57,6 +60,20 @@ def _find_and_load_global_skill_files(skill_dir: Path) -> list[Skill]:
return skills return skills
def load_sandbox_skills(sandbox: SandboxInfo) -> list[Skill]:
"""Load skills specific to the sandbox, including exposed ports / urls."""
if not sandbox.exposed_urls:
return []
urls = [url for url in sandbox.exposed_urls if url.name.startswith('WORKER_')]
if not urls:
return []
content_list = [WORK_HOSTS_SKILL]
for url in urls:
content_list.append(f'* {url.url} (port {url.port})')
content = '\n'.join(content_list)
return [Skill(name='work_hosts', content=content, trigger=None)]
def load_global_skills() -> list[Skill]: def load_global_skills() -> list[Skill]:
"""Load global skills from OpenHands/skills/ directory. """Load global skills from OpenHands/skills/ directory.

View File

@@ -162,6 +162,7 @@ class DockerSandboxService(SandboxService):
ExposedUrl( ExposedUrl(
name=exposed_port.name, name=exposed_port.name,
url=url, url=url,
port=host_port,
) )
) )

View File

@@ -208,6 +208,7 @@ class ProcessSandboxService(SandboxService):
ExposedUrl( ExposedUrl(
name=AGENT_SERVER, name=AGENT_SERVER,
url=f'http://localhost:{process_info.port}', url=f'http://localhost:{process_info.port}',
port=process_info.port,
), ),
] ]
session_api_key = process_info.session_api_key session_api_key = process_info.session_api_key

View File

@@ -64,6 +64,10 @@ STATUS_MAPPING = {
'starting': SandboxStatus.STARTING, 'starting': SandboxStatus.STARTING,
'error': SandboxStatus.ERROR, 'error': SandboxStatus.ERROR,
} }
AGENT_SERVER_PORT = 60000
VSCODE_PORT = 60001
WORKER_1_PORT = 12000
WORKER_2_PORT = 12001
class StoredRemoteSandbox(Base): # type: ignore class StoredRemoteSandbox(Base): # type: ignore
@@ -138,17 +142,29 @@ class RemoteSandboxService(SandboxService):
exposed_urls = [] exposed_urls = []
url = runtime.get('url', None) url = runtime.get('url', None)
if url: if url:
exposed_urls.append(ExposedUrl(name=AGENT_SERVER, url=url)) exposed_urls.append(
ExposedUrl(name=AGENT_SERVER, url=url, port=AGENT_SERVER_PORT)
)
vscode_url = ( vscode_url = (
_build_service_url(url, 'vscode') _build_service_url(url, 'vscode')
+ f'/?tkn={session_api_key}&folder=%2Fworkspace%2Fproject' + f'/?tkn={session_api_key}&folder=%2Fworkspace%2Fproject'
) )
exposed_urls.append(ExposedUrl(name=VSCODE, url=vscode_url))
exposed_urls.append( exposed_urls.append(
ExposedUrl(name=WORKER_1, url=_build_service_url(url, 'work-1')) ExposedUrl(name=VSCODE, url=vscode_url, port=VSCODE_PORT)
) )
exposed_urls.append( exposed_urls.append(
ExposedUrl(name=WORKER_2, url=_build_service_url(url, 'work-2')) ExposedUrl(
name=WORKER_1,
url=_build_service_url(url, 'work-1'),
port=WORKER_1_PORT,
)
)
exposed_urls.append(
ExposedUrl(
name=WORKER_2,
url=_build_service_url(url, 'work-2'),
port=WORKER_2_PORT,
)
) )
else: else:
exposed_urls = None exposed_urls = None

View File

@@ -20,6 +20,7 @@ class ExposedUrl(BaseModel):
name: str name: str
url: str url: str
port: int
# Standard names # Standard names

View File

@@ -9,6 +9,7 @@ import pytest
from openhands.app_server.app_conversation.live_status_app_conversation_service import ( from openhands.app_server.app_conversation.live_status_app_conversation_service import (
LiveStatusAppConversationService, LiveStatusAppConversationService,
) )
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
from openhands.experiments.experiment_manager import ExperimentManager from openhands.experiments.experiment_manager import ExperimentManager
from openhands.sdk import Agent from openhands.sdk import Agent
from openhands.sdk.llm import LLM from openhands.sdk.llm import LLM
@@ -191,6 +192,14 @@ class TestExperimentManagerIntegration:
access_token_hard_timeout=None, access_token_hard_timeout=None,
) )
sandbox = SandboxInfo(
id='mock-sandbox-id',
created_by_user_id='mock-user-id',
sandbox_spec_id='mock-sandbox-spec-id',
status=SandboxStatus.RUNNING,
session_api_key='mock-session-api-key',
)
# Patch the pieces invoked by the service # Patch the pieces invoked by the service
with ( with (
patch( patch(
@@ -204,6 +213,7 @@ class TestExperimentManagerIntegration:
): ):
# --- Act: build the start request # --- Act: build the start request
start_req = await service._build_start_conversation_request_for_user( start_req = await service._build_start_conversation_request_for_user(
sandbox=sandbox,
initial_message=None, initial_message=None,
git_provider=None, # Keep secrets path simple git_provider=None, # Keep secrets path simple
working_dir='/tmp/project', # Arbitrary path working_dir='/tmp/project', # Arbitrary path

View File

@@ -2150,7 +2150,9 @@ async def test_delete_v1_conversation_with_sub_conversations():
sandbox_spec_id='test-spec-id', sandbox_spec_id='test-spec-id',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
session_api_key='test-api-key', session_api_key='test-api-key',
exposed_urls=[ExposedUrl(name=AGENT_SERVER, url='http://agent:8000')], exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
],
) )
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
@@ -2269,7 +2271,9 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
sandbox_spec_id='test-spec-id', sandbox_spec_id='test-spec-id',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
session_api_key='test-api-key', session_api_key='test-api-key',
exposed_urls=[ExposedUrl(name=AGENT_SERVER, url='http://agent:8000')], exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
],
) )
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
@@ -2418,7 +2422,9 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error():
sandbox_spec_id='test-spec-id', sandbox_spec_id='test-spec-id',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
session_api_key='test-api-key', session_api_key='test-api-key',
exposed_urls=[ExposedUrl(name=AGENT_SERVER, url='http://agent:8000')], exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
],
) )
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)