mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat: Load workspace hooks for V1 conversations and add hooks viewer UI (#12773)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: enyst <engel.nyst@gmail.com> Co-authored-by: Alona King <alona@all-hands.dev>
This commit is contained in:
@@ -242,3 +242,32 @@ class SkillResponse(BaseModel):
|
||||
type: Literal['repo', 'knowledge', 'agentskills']
|
||||
content: str
|
||||
triggers: list[str] = []
|
||||
|
||||
|
||||
class HookDefinitionResponse(BaseModel):
|
||||
"""Response model for a single hook definition."""
|
||||
|
||||
type: str # 'command' or 'prompt'
|
||||
command: str
|
||||
timeout: int = 60
|
||||
async_: bool = Field(default=False, serialization_alias='async')
|
||||
|
||||
|
||||
class HookMatcherResponse(BaseModel):
|
||||
"""Response model for a hook matcher."""
|
||||
|
||||
matcher: str # Pattern: '*', exact match, or regex
|
||||
hooks: list[HookDefinitionResponse] = []
|
||||
|
||||
|
||||
class HookEventResponse(BaseModel):
|
||||
"""Response model for hooks of a specific event type."""
|
||||
|
||||
event_type: str # e.g., 'stop', 'pre_tool_use', 'post_tool_use'
|
||||
matchers: list[HookMatcherResponse] = []
|
||||
|
||||
|
||||
class GetHooksResponse(BaseModel):
|
||||
"""Response model for hooks endpoint."""
|
||||
|
||||
hooks: list[HookEventResponse] = []
|
||||
|
||||
@@ -5,43 +5,29 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Annotated, AsyncGenerator, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
set_httpx_client_keep_open,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
# Handle anext compatibility for Python < 3.10
|
||||
if sys.version_info >= (3, 10):
|
||||
from builtins import anext
|
||||
else:
|
||||
|
||||
async def anext(async_iterator):
|
||||
"""Compatibility function for anext in Python < 3.10"""
|
||||
return await async_iterator.__anext__()
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversation,
|
||||
AppConversationInfo,
|
||||
AppConversationPage,
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTask,
|
||||
AppConversationStartTaskPage,
|
||||
AppConversationStartTaskSortOrder,
|
||||
AppConversationUpdateRequest,
|
||||
GetHooksResponse,
|
||||
HookDefinitionResponse,
|
||||
HookEventResponse,
|
||||
HookMatcherResponse,
|
||||
SkillResponse,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_service import (
|
||||
@@ -66,15 +52,35 @@ from openhands.app_server.config import (
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
|
||||
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
set_httpx_client_keep_open,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
|
||||
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
# Handle anext compatibility for Python < 3.10
|
||||
if sys.version_info >= (3, 10):
|
||||
from builtins import anext
|
||||
else:
|
||||
|
||||
async def anext(async_iterator):
|
||||
"""Compatibility function for anext in Python < 3.10"""
|
||||
return await async_iterator.__anext__()
|
||||
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
@@ -92,6 +98,96 @@ httpx_client_dependency = depends_httpx_client()
|
||||
sandbox_service_dependency = depends_sandbox_service()
|
||||
sandbox_spec_service_dependency = depends_sandbox_spec_service()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentServerContext:
|
||||
"""Context for accessing the agent server for a conversation."""
|
||||
|
||||
conversation: AppConversationInfo
|
||||
sandbox: SandboxInfo
|
||||
sandbox_spec: SandboxSpecInfo
|
||||
agent_server_url: str
|
||||
session_api_key: str | None
|
||||
|
||||
|
||||
async def _get_agent_server_context(
|
||||
conversation_id: UUID,
|
||||
app_conversation_service: AppConversationService,
|
||||
sandbox_service: SandboxService,
|
||||
sandbox_spec_service: SandboxSpecService,
|
||||
) -> AgentServerContext | JSONResponse:
|
||||
"""Get the agent server context for a conversation.
|
||||
|
||||
This helper retrieves all necessary information to communicate with the
|
||||
agent server for a given conversation, including the sandbox info,
|
||||
sandbox spec, and agent server URL.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
app_conversation_service: Service for conversation operations
|
||||
sandbox_service: Service for sandbox operations
|
||||
sandbox_spec_service: Service for sandbox spec operations
|
||||
|
||||
Returns:
|
||||
AgentServerContext if successful, or JSONResponse with error details.
|
||||
"""
|
||||
# Get the conversation info
|
||||
conversation = await app_conversation_service.get_app_conversation(conversation_id)
|
||||
if not conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Conversation {conversation_id} not found'},
|
||||
)
|
||||
|
||||
# Get the sandbox info
|
||||
sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
|
||||
if not sandbox or sandbox.status != SandboxStatus.RUNNING:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={
|
||||
'error': f'Sandbox not found or not running for conversation {conversation_id}'
|
||||
},
|
||||
)
|
||||
|
||||
# Get the sandbox spec to find the working directory
|
||||
sandbox_spec = await sandbox_spec_service.get_sandbox_spec(sandbox.sandbox_spec_id)
|
||||
if not sandbox_spec:
|
||||
# TODO: This is a temporary work around for the fact that we don't store previous
|
||||
# sandbox spec versions when updating OpenHands. When the SandboxSpecServices
|
||||
# transition to truly multi sandbox spec model this should raise a 404 error
|
||||
logger.warning('Sandbox spec not found - using default.')
|
||||
sandbox_spec = await sandbox_spec_service.get_default_sandbox_spec()
|
||||
|
||||
# Get the agent server URL
|
||||
if not sandbox.exposed_urls:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'No agent server URL found for sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = None
|
||||
for exposed_url in sandbox.exposed_urls:
|
||||
if exposed_url.name == AGENT_SERVER:
|
||||
agent_server_url = exposed_url.url
|
||||
break
|
||||
|
||||
if not agent_server_url:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Agent server URL not found in sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
|
||||
|
||||
return AgentServerContext(
|
||||
conversation=conversation,
|
||||
sandbox=sandbox,
|
||||
sandbox_spec=sandbox_spec,
|
||||
agent_server_url=agent_server_url,
|
||||
session_api_key=sandbox.session_api_key,
|
||||
)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
|
||||
@@ -493,57 +589,15 @@ async def get_conversation_skills(
|
||||
JSONResponse: A JSON response containing the list of skills.
|
||||
"""
|
||||
try:
|
||||
# Get the conversation info
|
||||
conversation = await app_conversation_service.get_app_conversation(
|
||||
conversation_id
|
||||
# Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url)
|
||||
ctx = await _get_agent_server_context(
|
||||
conversation_id,
|
||||
app_conversation_service,
|
||||
sandbox_service,
|
||||
sandbox_spec_service,
|
||||
)
|
||||
if not conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Conversation {conversation_id} not found'},
|
||||
)
|
||||
|
||||
# Get the sandbox info
|
||||
sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
|
||||
if not sandbox or sandbox.status != SandboxStatus.RUNNING:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={
|
||||
'error': f'Sandbox not found or not running for conversation {conversation_id}'
|
||||
},
|
||||
)
|
||||
|
||||
# Get the sandbox spec to find the working directory
|
||||
sandbox_spec = await sandbox_spec_service.get_sandbox_spec(
|
||||
sandbox.sandbox_spec_id
|
||||
)
|
||||
if not sandbox_spec:
|
||||
# TODO: This is a temporary work around for the fact that we don't store previous
|
||||
# sandbox spec versions when updating OpenHands. When the SandboxSpecServices
|
||||
# transition to truly multi sandbox spec model this should raise a 404 error
|
||||
logger.warning('Sandbox spec not found - using default.')
|
||||
sandbox_spec = await sandbox_spec_service.get_default_sandbox_spec()
|
||||
|
||||
# Get the agent server URL
|
||||
if not sandbox.exposed_urls:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'No agent server URL found for sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = None
|
||||
for exposed_url in sandbox.exposed_urls:
|
||||
if exposed_url.name == AGENT_SERVER:
|
||||
agent_server_url = exposed_url.url
|
||||
break
|
||||
|
||||
if not agent_server_url:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Agent server URL not found in sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
|
||||
if isinstance(ctx, JSONResponse):
|
||||
return ctx
|
||||
|
||||
# Load skills from all sources
|
||||
logger.info(f'Loading skills for conversation {conversation_id}')
|
||||
@@ -552,13 +606,13 @@ async def get_conversation_skills(
|
||||
all_skills: list = []
|
||||
if isinstance(app_conversation_service, AppConversationServiceBase):
|
||||
project_dir = get_project_dir(
|
||||
sandbox_spec.working_dir, conversation.selected_repository
|
||||
ctx.sandbox_spec.working_dir, ctx.conversation.selected_repository
|
||||
)
|
||||
all_skills = await app_conversation_service.load_and_merge_all_skills(
|
||||
sandbox,
|
||||
conversation.selected_repository,
|
||||
ctx.sandbox,
|
||||
ctx.conversation.selected_repository,
|
||||
project_dir,
|
||||
agent_server_url,
|
||||
ctx.agent_server_url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -608,6 +662,147 @@ async def get_conversation_skills(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/hooks')
|
||||
async def get_conversation_hooks(
|
||||
conversation_id: UUID,
|
||||
app_conversation_service: AppConversationService = (
|
||||
app_conversation_service_dependency
|
||||
),
|
||||
sandbox_service: SandboxService = sandbox_service_dependency,
|
||||
sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
|
||||
httpx_client: httpx.AsyncClient = httpx_client_dependency,
|
||||
) -> JSONResponse:
|
||||
"""Get hooks currently configured in the workspace for this conversation.
|
||||
|
||||
This endpoint loads hooks from the conversation's project directory in the
|
||||
workspace (i.e. `{project_dir}/.openhands/hooks.json`) at request time.
|
||||
|
||||
Note:
|
||||
This is intentionally a "live" view of the workspace configuration.
|
||||
If `.openhands/hooks.json` changes over time, this endpoint reflects the
|
||||
latest file content and may not match the hooks that were used when the
|
||||
conversation originally started.
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the list of hook event types.
|
||||
"""
|
||||
try:
|
||||
# Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url)
|
||||
ctx = await _get_agent_server_context(
|
||||
conversation_id,
|
||||
app_conversation_service,
|
||||
sandbox_service,
|
||||
sandbox_spec_service,
|
||||
)
|
||||
if isinstance(ctx, JSONResponse):
|
||||
return ctx
|
||||
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
fetch_hooks_from_agent_server,
|
||||
get_project_dir_for_hooks,
|
||||
)
|
||||
|
||||
project_dir = get_project_dir_for_hooks(
|
||||
ctx.sandbox_spec.working_dir,
|
||||
ctx.conversation.selected_repository,
|
||||
)
|
||||
|
||||
# Load hooks from agent-server (using the error-raising variant so
|
||||
# HTTP/connection failures are surfaced to the user, not hidden).
|
||||
logger.debug(
|
||||
f'Loading hooks for conversation {conversation_id}, '
|
||||
f'agent_server_url={ctx.agent_server_url}, '
|
||||
f'project_dir={project_dir}'
|
||||
)
|
||||
|
||||
try:
|
||||
hook_config = await fetch_hooks_from_agent_server(
|
||||
agent_server_url=ctx.agent_server_url,
|
||||
session_api_key=ctx.session_api_key,
|
||||
project_dir=project_dir,
|
||||
httpx_client=httpx_client,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
f'Agent-server returned {e.response.status_code} when loading hooks '
|
||||
f'for conversation {conversation_id}: {e.response.text}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={
|
||||
'error': f'Agent-server returned status {e.response.status_code} when loading hooks'
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
f'Failed to reach agent-server when loading hooks '
|
||||
f'for conversation {conversation_id}: {e}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={'error': 'Failed to reach agent-server when loading hooks'},
|
||||
)
|
||||
|
||||
# Transform hook_config to response format
|
||||
hooks_response: list[HookEventResponse] = []
|
||||
|
||||
if hook_config:
|
||||
# Define the event types to check
|
||||
event_types = [
|
||||
'pre_tool_use',
|
||||
'post_tool_use',
|
||||
'user_prompt_submit',
|
||||
'session_start',
|
||||
'session_end',
|
||||
'stop',
|
||||
]
|
||||
|
||||
for field_name in event_types:
|
||||
matchers = getattr(hook_config, field_name, [])
|
||||
if matchers:
|
||||
matcher_responses = []
|
||||
for matcher in matchers:
|
||||
hook_defs = [
|
||||
HookDefinitionResponse(
|
||||
type=hook.type.value
|
||||
if hasattr(hook.type, 'value')
|
||||
else str(hook.type),
|
||||
command=hook.command,
|
||||
timeout=hook.timeout,
|
||||
async_=hook.async_,
|
||||
)
|
||||
for hook in matcher.hooks
|
||||
]
|
||||
matcher_responses.append(
|
||||
HookMatcherResponse(
|
||||
matcher=matcher.matcher,
|
||||
hooks=hook_defs,
|
||||
)
|
||||
)
|
||||
hooks_response.append(
|
||||
HookEventResponse(
|
||||
event_type=field_name,
|
||||
matchers=matcher_responses,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Loaded {len(hooks_response)} hook event types for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=GetHooksResponse(hooks=hooks_response).model_dump(by_alias=True),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting hooks for conversation {conversation_id}: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error getting hooks: {str(e)}'},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/download')
|
||||
async def export_conversation(
|
||||
conversation_id: UUID,
|
||||
|
||||
148
openhands/app_server/app_conversation/hook_loader.py
Normal file
148
openhands/app_server/app_conversation/hook_loader.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Utilities for loading hooks for V1 conversations.
|
||||
|
||||
This module provides functions to load hooks from the agent-server,
|
||||
which centralizes all hook loading logic. The app-server acts as a
|
||||
thin proxy that calls the agent-server's /api/hooks endpoint.
|
||||
|
||||
All hook loading is handled by the agent-server.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.sdk.hooks import HookConfig
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_project_dir_for_hooks(
|
||||
working_dir: str,
|
||||
selected_repository: str | None = None,
|
||||
) -> str:
|
||||
"""Get the project directory path for loading hooks.
|
||||
|
||||
When a repository is selected, hooks are loaded from
|
||||
{working_dir}/{repo_name}/.openhands/hooks.json.
|
||||
Otherwise, hooks are loaded from {working_dir}/.openhands/hooks.json.
|
||||
|
||||
Args:
|
||||
working_dir: Base working directory path in the sandbox
|
||||
selected_repository: Repository name (e.g., 'OpenHands/software-agent-sdk')
|
||||
If provided, the repo name is appended to working_dir.
|
||||
|
||||
Returns:
|
||||
The project directory path where hooks.json should be located.
|
||||
"""
|
||||
if selected_repository:
|
||||
repo_name = selected_repository.split('/')[-1]
|
||||
return f'{working_dir}/{repo_name}'
|
||||
return working_dir
|
||||
|
||||
|
||||
async def fetch_hooks_from_agent_server(
|
||||
agent_server_url: str,
|
||||
session_api_key: str | None,
|
||||
project_dir: str,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
) -> HookConfig | None:
|
||||
"""Fetch hooks from the agent-server, raising on HTTP/connection errors.
|
||||
|
||||
This is the low-level function that makes a single API call to the
|
||||
agent-server's /api/hooks endpoint. It raises on HTTP and connection
|
||||
errors so callers can decide how to handle failures.
|
||||
|
||||
Args:
|
||||
agent_server_url: URL of the agent server (e.g., 'http://localhost:8000')
|
||||
session_api_key: Session API key for authentication (optional)
|
||||
project_dir: Workspace directory path for project hooks
|
||||
httpx_client: Shared HTTP client for making the request
|
||||
|
||||
Returns:
|
||||
HookConfig if hooks.json exists and is valid, None if no hooks found.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the agent-server returns a non-2xx status.
|
||||
httpx.RequestError: If the agent-server is unreachable.
|
||||
"""
|
||||
_logger.debug(
|
||||
f'fetch_hooks_from_agent_server called: '
|
||||
f'agent_server_url={agent_server_url}, project_dir={project_dir}'
|
||||
)
|
||||
payload = {'project_dir': project_dir}
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if session_api_key:
|
||||
headers['X-Session-API-Key'] = session_api_key
|
||||
|
||||
response = await httpx_client.post(
|
||||
f'{agent_server_url}/api/hooks',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
hook_config_data = data.get('hook_config')
|
||||
if hook_config_data is None:
|
||||
_logger.debug('No hooks found in workspace')
|
||||
return None
|
||||
|
||||
hook_config = HookConfig.from_dict(hook_config_data)
|
||||
|
||||
if hook_config.is_empty():
|
||||
_logger.debug('Hooks config is empty')
|
||||
return None
|
||||
|
||||
_logger.debug(f'Loaded hooks from agent-server for {project_dir}')
|
||||
return hook_config
|
||||
|
||||
|
||||
async def load_hooks_from_agent_server(
|
||||
agent_server_url: str,
|
||||
session_api_key: str | None,
|
||||
project_dir: str,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
) -> HookConfig | None:
|
||||
"""Load hooks from the agent-server, swallowing errors gracefully.
|
||||
|
||||
Wrapper around fetch_hooks_from_agent_server that catches all errors
|
||||
and returns None. Use this for the conversation-start path where hooks
|
||||
are optional and failures should not block startup.
|
||||
|
||||
For the hooks viewer endpoint, use fetch_hooks_from_agent_server directly
|
||||
so errors can be surfaced to the user.
|
||||
|
||||
Args:
|
||||
agent_server_url: URL of the agent server (e.g., 'http://localhost:8000')
|
||||
session_api_key: Session API key for authentication (optional)
|
||||
project_dir: Workspace directory path for project hooks
|
||||
httpx_client: Shared HTTP client for making the request
|
||||
|
||||
Returns:
|
||||
HookConfig if hooks.json exists and is valid, None otherwise.
|
||||
"""
|
||||
try:
|
||||
return await fetch_hooks_from_agent_server(
|
||||
agent_server_url, session_api_key, project_dir, httpx_client
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
_logger.warning(
|
||||
f'Agent-server at {agent_server_url} returned error status {e.response.status_code} '
|
||||
f'when loading hooks from {project_dir}: {e.response.text}'
|
||||
)
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
_logger.warning(
|
||||
f'Failed to connect to agent-server at {agent_server_url} '
|
||||
f'when loading hooks from {project_dir}: {e}'
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
f'Failed to load hooks from agent-server at {agent_server_url} '
|
||||
f'for project {project_dir}: {e}'
|
||||
)
|
||||
return None
|
||||
@@ -46,6 +46,9 @@ from openhands.app_server.app_conversation.app_conversation_service_base import
|
||||
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
|
||||
AppConversationStartTaskService,
|
||||
)
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
load_hooks_from_agent_server,
|
||||
)
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
@@ -84,6 +87,7 @@ from openhands.app_server.utils.llm_metadata import (
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.integrations.service_types import SuggestedTask
|
||||
from openhands.sdk import Agent, AgentContext, LocalWorkspace
|
||||
from openhands.sdk.hooks import HookConfig
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.plugin import PluginSource
|
||||
from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret
|
||||
@@ -312,6 +316,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
body_json = start_conversation_request.model_dump(
|
||||
mode='json', context={'expose_secrets': True}
|
||||
)
|
||||
# Log hook_config to verify it's being passed
|
||||
hook_config_in_request = body_json.get('hook_config')
|
||||
_logger.debug(
|
||||
f'Sending StartConversationRequest with hook_config: '
|
||||
f'{hook_config_in_request}'
|
||||
)
|
||||
response = await self.httpx_client.post(
|
||||
f'{agent_server_url}/api/conversations',
|
||||
json=body_json,
|
||||
@@ -1295,6 +1305,46 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
run=initial_message.run,
|
||||
)
|
||||
|
||||
async def _load_hooks_from_workspace(
|
||||
self,
|
||||
remote_workspace: AsyncRemoteWorkspace,
|
||||
project_dir: str,
|
||||
) -> HookConfig | None:
|
||||
"""Load hooks from .openhands/hooks.json in the remote workspace.
|
||||
|
||||
This enables project-level hooks to be automatically loaded when starting
|
||||
a conversation, similar to how OpenHands-CLI loads hooks from the workspace.
|
||||
|
||||
Uses the agent-server's /api/hooks endpoint, consistent with how skills
|
||||
are loaded via /api/skills.
|
||||
|
||||
Args:
|
||||
remote_workspace: AsyncRemoteWorkspace for accessing the agent server
|
||||
project_dir: Project root directory path in the sandbox. This should
|
||||
already be the resolved project directory (e.g.,
|
||||
{working_dir}/{repo_name} when a repo is selected).
|
||||
|
||||
Returns:
|
||||
HookConfig if hooks.json exists and is valid, None otherwise.
|
||||
Returns None in the following cases:
|
||||
- hooks.json file does not exist
|
||||
- hooks.json contains invalid JSON
|
||||
- hooks.json contains an empty hooks configuration
|
||||
- Agent server is unreachable or returns an error
|
||||
|
||||
Note:
|
||||
This method implements graceful degradation - if hooks cannot be loaded
|
||||
for any reason, it returns None rather than raising an exception. This
|
||||
ensures that conversation startup is not blocked by hook loading failures.
|
||||
Errors are logged as warnings for debugging purposes.
|
||||
"""
|
||||
return await load_hooks_from_agent_server(
|
||||
agent_server_url=remote_workspace.host,
|
||||
session_api_key=remote_workspace._headers.get('X-Session-API-Key'),
|
||||
project_dir=project_dir,
|
||||
httpx_client=self.httpx_client,
|
||||
)
|
||||
|
||||
async def _finalize_conversation_request(
|
||||
self,
|
||||
agent: Agent,
|
||||
@@ -1334,6 +1384,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
agent = self._update_agent_with_llm_metadata(agent, conversation_id, user.id)
|
||||
|
||||
# Load and merge skills if remote workspace is available
|
||||
hook_config: HookConfig | None = None
|
||||
if remote_workspace:
|
||||
try:
|
||||
agent = await self._load_skills_and_update_agent(
|
||||
@@ -1343,6 +1394,28 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
|
||||
# Continue without skills - don't fail conversation startup
|
||||
|
||||
# Load hooks from workspace (.openhands/hooks.json)
|
||||
# Note: working_dir is already the resolved project_dir
|
||||
# (includes repo name when a repo is selected), so we pass
|
||||
# it directly without appending the repo name again.
|
||||
try:
|
||||
_logger.debug(
|
||||
f'Attempting to load hooks from workspace: '
|
||||
f'project_dir={working_dir}'
|
||||
)
|
||||
hook_config = await self._load_hooks_from_workspace(
|
||||
remote_workspace, working_dir
|
||||
)
|
||||
if hook_config:
|
||||
_logger.debug(
|
||||
f'Successfully loaded hooks: {hook_config.model_dump()}'
|
||||
)
|
||||
else:
|
||||
_logger.debug('No hooks found in workspace')
|
||||
except Exception as e:
|
||||
_logger.warning(f'Failed to load hooks: {e}', exc_info=True)
|
||||
# Continue without hooks - don't fail conversation startup
|
||||
|
||||
# Incorporate plugin parameters into initial message if specified
|
||||
final_initial_message = self._construct_initial_message_with_plugin_params(
|
||||
initial_message, plugins
|
||||
@@ -1371,6 +1444,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
initial_message=final_initial_message,
|
||||
secrets=secrets,
|
||||
plugins=sdk_plugins,
|
||||
hook_config=hook_config,
|
||||
)
|
||||
|
||||
async def _build_start_conversation_request_for_user(
|
||||
|
||||
Reference in New Issue
Block a user