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:
Xingyao Wang
2026-03-17 00:55:23 +08:00
committed by GitHub
parent a0e777503e
commit 00daaa41d3
37 changed files with 2452 additions and 84 deletions

View File

@@ -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] = []

View File

@@ -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,

View 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

View File

@@ -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(