mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
494 lines
18 KiB
Python
494 lines
18 KiB
Python
import logging
|
|
import os
|
|
import tempfile
|
|
from abc import ABC
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, AsyncGenerator
|
|
from uuid import UUID
|
|
|
|
if TYPE_CHECKING:
|
|
import httpx
|
|
|
|
import base62
|
|
|
|
from openhands.app_server.app_conversation.app_conversation_models import (
|
|
AgentType,
|
|
AppConversationStartTask,
|
|
AppConversationStartTaskStatus,
|
|
)
|
|
from openhands.app_server.app_conversation.app_conversation_service import (
|
|
AppConversationService,
|
|
)
|
|
from openhands.app_server.app_conversation.skill_loader import (
|
|
load_global_skills,
|
|
load_org_skills,
|
|
load_repo_skills,
|
|
load_sandbox_skills,
|
|
merge_skills,
|
|
)
|
|
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
|
|
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.condenser import LLMSummarizingCondenser
|
|
from openhands.sdk.context.skills import load_user_skills
|
|
from openhands.sdk.llm import LLM
|
|
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
from openhands.sdk.security.confirmation_policy import (
|
|
AlwaysConfirm,
|
|
ConfirmationPolicyBase,
|
|
ConfirmRisky,
|
|
NeverConfirm,
|
|
)
|
|
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
|
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
|
|
PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
|
|
|
|
|
|
@dataclass
|
|
class AppConversationServiceBase(AppConversationService, ABC):
|
|
"""App Conversation service which adds git specific functionality.
|
|
|
|
Sets up repositories and installs hooks"""
|
|
|
|
init_git_in_empty_workspace: bool
|
|
user_context: UserContext
|
|
|
|
async def load_and_merge_all_skills(
|
|
self,
|
|
sandbox: SandboxInfo,
|
|
remote_workspace: AsyncRemoteWorkspace,
|
|
selected_repository: str | None,
|
|
working_dir: str,
|
|
) -> list:
|
|
"""Load skills from all sources and merge them.
|
|
|
|
This method handles all errors gracefully and will return an empty list
|
|
if skill loading fails completely.
|
|
|
|
Args:
|
|
remote_workspace: AsyncRemoteWorkspace for loading repo skills
|
|
selected_repository: Repository name or None
|
|
working_dir: Working directory path
|
|
|
|
Returns:
|
|
List of merged Skill objects from all sources, or empty list on failure
|
|
"""
|
|
try:
|
|
_logger.debug('Loading skills for V1 conversation')
|
|
|
|
# Load skills from all sources
|
|
sandbox_skills = load_sandbox_skills(sandbox)
|
|
global_skills = load_global_skills()
|
|
# Load user skills from ~/.openhands/skills/ directory
|
|
# Uses the SDK's load_user_skills() function which handles loading from
|
|
# ~/.openhands/skills/ and ~/.openhands/microagents/ (for backward compatibility)
|
|
try:
|
|
user_skills = load_user_skills()
|
|
_logger.info(
|
|
f'Loaded {len(user_skills)} user skills: {[s.name for s in user_skills]}'
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(f'Failed to load user skills: {str(e)}')
|
|
user_skills = []
|
|
|
|
# Load organization-level skills
|
|
org_skills = await load_org_skills(
|
|
remote_workspace, selected_repository, working_dir, self.user_context
|
|
)
|
|
|
|
repo_skills = await load_repo_skills(
|
|
remote_workspace, selected_repository, working_dir
|
|
)
|
|
|
|
# Merge all skills (later lists override earlier ones)
|
|
# Precedence: sandbox < global < user < org < repo
|
|
all_skills = merge_skills(
|
|
[sandbox_skills, global_skills, user_skills, org_skills, repo_skills]
|
|
)
|
|
|
|
_logger.info(
|
|
f'Loaded {len(all_skills)} total skills: {[s.name for s in all_skills]}'
|
|
)
|
|
|
|
return all_skills
|
|
except Exception as e:
|
|
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
|
|
# Return empty list on failure - skills will be loaded again later if needed
|
|
return []
|
|
|
|
def _create_agent_with_skills(self, agent, skills: list):
|
|
"""Create or update agent with skills in its context.
|
|
|
|
Args:
|
|
agent: The agent to update
|
|
skills: List of Skill objects to add to agent context
|
|
|
|
Returns:
|
|
Updated agent with skills in context
|
|
"""
|
|
if agent.agent_context:
|
|
# Merge with existing context
|
|
existing_skills = agent.agent_context.skills
|
|
all_skills = merge_skills([skills, existing_skills])
|
|
agent = agent.model_copy(
|
|
update={
|
|
'agent_context': agent.agent_context.model_copy(
|
|
update={'skills': all_skills}
|
|
)
|
|
}
|
|
)
|
|
else:
|
|
# Create new context
|
|
agent_context = AgentContext(skills=skills)
|
|
agent = agent.model_copy(update={'agent_context': agent_context})
|
|
|
|
return agent
|
|
|
|
async def _load_skills_and_update_agent(
|
|
self,
|
|
sandbox: SandboxInfo,
|
|
agent: Agent,
|
|
remote_workspace: AsyncRemoteWorkspace,
|
|
selected_repository: str | None,
|
|
working_dir: str,
|
|
):
|
|
"""Load all skills and update agent with them.
|
|
|
|
Args:
|
|
agent: The agent to update
|
|
remote_workspace: AsyncRemoteWorkspace for loading repo skills
|
|
selected_repository: Repository name or None
|
|
working_dir: Working directory path
|
|
|
|
Returns:
|
|
Updated agent with skills loaded into context
|
|
"""
|
|
# Load and merge all skills
|
|
all_skills = await self.load_and_merge_all_skills(
|
|
sandbox, remote_workspace, selected_repository, working_dir
|
|
)
|
|
|
|
# Update agent with skills
|
|
agent = self._create_agent_with_skills(agent, all_skills)
|
|
|
|
return agent
|
|
|
|
async def run_setup_scripts(
|
|
self,
|
|
task: AppConversationStartTask,
|
|
sandbox: SandboxInfo,
|
|
workspace: AsyncRemoteWorkspace,
|
|
) -> AsyncGenerator[AppConversationStartTask, None]:
|
|
task.status = AppConversationStartTaskStatus.PREPARING_REPOSITORY
|
|
yield task
|
|
await self.clone_or_init_git_repo(task, workspace)
|
|
|
|
task.status = AppConversationStartTaskStatus.RUNNING_SETUP_SCRIPT
|
|
yield task
|
|
await self.maybe_run_setup_script(workspace)
|
|
|
|
task.status = AppConversationStartTaskStatus.SETTING_UP_GIT_HOOKS
|
|
yield task
|
|
await self.maybe_setup_git_hooks(workspace)
|
|
|
|
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
|
|
yield task
|
|
await self.load_and_merge_all_skills(
|
|
sandbox,
|
|
workspace,
|
|
task.request.selected_repository,
|
|
workspace.working_dir,
|
|
)
|
|
|
|
async def _configure_git_user_settings(
|
|
self,
|
|
workspace: AsyncRemoteWorkspace,
|
|
) -> None:
|
|
"""Configure git global user settings from user preferences.
|
|
|
|
Reads git_user_name and git_user_email from user settings and
|
|
configures them as git global settings in the workspace.
|
|
|
|
Args:
|
|
workspace: The remote workspace to configure git settings in.
|
|
"""
|
|
try:
|
|
user_info = await self.user_context.get_user_info()
|
|
|
|
if user_info.git_user_name:
|
|
cmd = f'git config --global user.name "{user_info.git_user_name}"'
|
|
result = await workspace.execute_command(cmd, workspace.working_dir)
|
|
if result.exit_code:
|
|
_logger.warning(f'Git config user.name failed: {result.stderr}')
|
|
else:
|
|
_logger.info(
|
|
f'Git configured with user.name={user_info.git_user_name}'
|
|
)
|
|
|
|
if user_info.git_user_email:
|
|
cmd = f'git config --global user.email "{user_info.git_user_email}"'
|
|
result = await workspace.execute_command(cmd, workspace.working_dir)
|
|
if result.exit_code:
|
|
_logger.warning(f'Git config user.email failed: {result.stderr}')
|
|
else:
|
|
_logger.info(
|
|
f'Git configured with user.email={user_info.git_user_email}'
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(f'Failed to configure git user settings: {e}')
|
|
|
|
async def clone_or_init_git_repo(
|
|
self,
|
|
task: AppConversationStartTask,
|
|
workspace: AsyncRemoteWorkspace,
|
|
):
|
|
request = task.request
|
|
|
|
# Create the projects directory if it does not exist yet
|
|
parent = Path(workspace.working_dir).parent
|
|
result = await workspace.execute_command(
|
|
f'mkdir {workspace.working_dir}', parent
|
|
)
|
|
if result.exit_code:
|
|
_logger.warning(f'mkdir failed: {result.stderr}')
|
|
|
|
# Configure git user settings from user preferences
|
|
await self._configure_git_user_settings(workspace)
|
|
|
|
if not request.selected_repository:
|
|
if self.init_git_in_empty_workspace:
|
|
_logger.debug('Initializing a new git repository in the workspace.')
|
|
cmd = (
|
|
'git init && git config --global '
|
|
f'--add safe.directory {workspace.working_dir}'
|
|
)
|
|
result = await workspace.execute_command(cmd, workspace.working_dir)
|
|
if result.exit_code:
|
|
_logger.warning(f'Git init failed: {result.stderr}')
|
|
else:
|
|
_logger.info('Not initializing a new git repository.')
|
|
return
|
|
|
|
remote_repo_url: str = await self.user_context.get_authenticated_git_url(
|
|
request.selected_repository
|
|
)
|
|
if not remote_repo_url:
|
|
raise ValueError('Missing either Git token or valid repository')
|
|
|
|
dir_name = request.selected_repository.split('/')[-1]
|
|
|
|
# Clone the repo - this is the slow part!
|
|
clone_command = f'git clone {remote_repo_url} {dir_name}'
|
|
result = await workspace.execute_command(
|
|
clone_command, workspace.working_dir, 120
|
|
)
|
|
if result.exit_code:
|
|
_logger.warning(f'Git clone failed: {result.stderr}')
|
|
|
|
# Checkout the appropriate branch
|
|
if request.selected_branch:
|
|
checkout_command = f'git checkout {request.selected_branch}'
|
|
else:
|
|
# Generate a random branch name to avoid conflicts
|
|
random_str = base62.encodebytes(os.urandom(16))
|
|
openhands_workspace_branch = f'openhands-workspace-{random_str}'
|
|
checkout_command = f'git checkout -b {openhands_workspace_branch}'
|
|
git_dir = Path(workspace.working_dir) / dir_name
|
|
result = await workspace.execute_command(checkout_command, git_dir)
|
|
if result.exit_code:
|
|
_logger.warning(f'Git checkout failed: {result.stderr}')
|
|
|
|
async def maybe_run_setup_script(
|
|
self,
|
|
workspace: AsyncRemoteWorkspace,
|
|
):
|
|
"""Run .openhands/setup.sh if it exists in the workspace or repository."""
|
|
setup_script = workspace.working_dir + '/.openhands/setup.sh'
|
|
|
|
await workspace.execute_command(
|
|
f'chmod +x {setup_script} && source {setup_script}', timeout=600
|
|
)
|
|
|
|
# TODO: Does this need to be done?
|
|
# Add the action to the event stream as an ENVIRONMENT event
|
|
# source = EventSource.ENVIRONMENT
|
|
# self.event_stream.add_event(action, source)
|
|
|
|
async def maybe_setup_git_hooks(
|
|
self,
|
|
workspace: AsyncRemoteWorkspace,
|
|
):
|
|
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
|
|
command = 'mkdir -p .git/hooks && chmod +x .openhands/pre-commit.sh'
|
|
result = await workspace.execute_command(command, workspace.working_dir)
|
|
if result.exit_code:
|
|
return
|
|
|
|
# Check if there's an existing pre-commit hook
|
|
with tempfile.TemporaryFile(mode='w+t') as temp_file:
|
|
result = workspace.file_download(PRE_COMMIT_HOOK, str(temp_file))
|
|
if result.get('success'):
|
|
_logger.info('Preserving existing pre-commit hook')
|
|
# an existing pre-commit hook exists
|
|
if 'This hook was installed by OpenHands' not in temp_file.read():
|
|
# Move the existing hook to pre-commit.local
|
|
command = (
|
|
f'mv {PRE_COMMIT_HOOK} {PRE_COMMIT_LOCAL} &&'
|
|
f'chmod +x {PRE_COMMIT_LOCAL}'
|
|
)
|
|
result = await workspace.execute_command(
|
|
command, workspace.working_dir
|
|
)
|
|
if result.exit_code != 0:
|
|
_logger.error(
|
|
f'Failed to preserve existing pre-commit hook: {result.stderr}',
|
|
)
|
|
return
|
|
|
|
# write the pre-commit hook
|
|
await workspace.file_upload(
|
|
source_path=Path(__file__).parent / 'git' / 'pre-commit.sh',
|
|
destination_path=PRE_COMMIT_HOOK,
|
|
)
|
|
|
|
# Make the pre-commit hook executable
|
|
result = await workspace.execute_command(f'chmod +x {PRE_COMMIT_HOOK}')
|
|
if result.exit_code:
|
|
_logger.error(f'Failed to make pre-commit hook executable: {result.stderr}')
|
|
return
|
|
|
|
_logger.info('Git pre-commit hook installed successfully')
|
|
|
|
def _create_condenser(
|
|
self,
|
|
llm: LLM,
|
|
agent_type: AgentType,
|
|
condenser_max_size: int | None,
|
|
) -> LLMSummarizingCondenser:
|
|
"""Create a condenser based on user settings and agent type.
|
|
|
|
Args:
|
|
llm: The LLM instance to use for condensation
|
|
agent_type: Type of agent (PLAN or DEFAULT)
|
|
condenser_max_size: condenser_max_size setting
|
|
|
|
Returns:
|
|
Configured LLMSummarizingCondenser instance
|
|
"""
|
|
# LLMSummarizingCondenser has defaults: max_size=120, keep_first=4
|
|
condenser_kwargs = {
|
|
'llm': llm.model_copy(
|
|
update={
|
|
'usage_id': (
|
|
'condenser'
|
|
if agent_type == AgentType.DEFAULT
|
|
else 'planning_condenser'
|
|
)
|
|
}
|
|
),
|
|
}
|
|
# Only override max_size if user has a custom value
|
|
if condenser_max_size is not None:
|
|
condenser_kwargs['max_size'] = condenser_max_size
|
|
|
|
condenser = LLMSummarizingCondenser(**condenser_kwargs)
|
|
|
|
return condenser
|
|
|
|
def _create_security_analyzer_from_string(
|
|
self, security_analyzer_str: str | None
|
|
) -> SecurityAnalyzerBase | None:
|
|
"""Convert security analyzer string from settings to SecurityAnalyzerBase instance.
|
|
|
|
Args:
|
|
security_analyzer_str: String value from settings. Valid values:
|
|
- "llm" -> LLMSecurityAnalyzer
|
|
- "none" or None -> None
|
|
- Other values -> None (unsupported analyzers are ignored)
|
|
|
|
Returns:
|
|
SecurityAnalyzerBase instance or None
|
|
"""
|
|
if not security_analyzer_str or security_analyzer_str.lower() == 'none':
|
|
return None
|
|
|
|
if security_analyzer_str.lower() == 'llm':
|
|
return LLMSecurityAnalyzer()
|
|
|
|
# For unknown values, log a warning and return None
|
|
_logger.warning(
|
|
f'Unknown security analyzer value: {security_analyzer_str}. '
|
|
'Supported values: "llm", "none". Defaulting to None.'
|
|
)
|
|
return None
|
|
|
|
def _select_confirmation_policy(
|
|
self, confirmation_mode: bool, security_analyzer: str | None
|
|
) -> ConfirmationPolicyBase:
|
|
"""Choose confirmation policy using only mode flag and analyzer string."""
|
|
if not confirmation_mode:
|
|
return NeverConfirm()
|
|
|
|
analyzer_kind = (security_analyzer or '').lower()
|
|
if analyzer_kind == 'llm':
|
|
return ConfirmRisky()
|
|
|
|
return AlwaysConfirm()
|
|
|
|
async def _set_security_analyzer_from_settings(
|
|
self,
|
|
agent_server_url: str,
|
|
session_api_key: str | None,
|
|
conversation_id: UUID,
|
|
security_analyzer_str: str | None,
|
|
httpx_client: 'httpx.AsyncClient',
|
|
) -> None:
|
|
"""Set security analyzer on conversation using only the analyzer string.
|
|
|
|
Args:
|
|
agent_server_url: URL of the agent server
|
|
session_api_key: Session API key for authentication
|
|
conversation_id: ID of the conversation to update
|
|
security_analyzer_str: String value from settings
|
|
httpx_client: HTTP client for making API requests
|
|
"""
|
|
|
|
if session_api_key is None:
|
|
return
|
|
|
|
security_analyzer = self._create_security_analyzer_from_string(
|
|
security_analyzer_str
|
|
)
|
|
|
|
# Only make API call if we have a security analyzer to set
|
|
# (None is the default, so we can skip the call if it's None)
|
|
if security_analyzer is None:
|
|
return
|
|
|
|
try:
|
|
# Prepare the request payload
|
|
payload = {'security_analyzer': security_analyzer.model_dump()}
|
|
|
|
# Call agent server API to set security analyzer
|
|
response = await httpx_client.post(
|
|
f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer',
|
|
json=payload,
|
|
headers={'X-Session-API-Key': session_api_key},
|
|
timeout=30.0,
|
|
)
|
|
response.raise_for_status()
|
|
_logger.info(
|
|
f'Successfully set security analyzer for conversation {conversation_id}'
|
|
)
|
|
except Exception as e:
|
|
# Log error but don't fail conversation creation
|
|
_logger.warning(
|
|
f'Failed to set security analyzer for conversation {conversation_id}: {e}',
|
|
exc_info=True,
|
|
)
|