feat: show available skills for v1 conversations (#12039)

This commit is contained in:
Hiep Le
2025-12-17 23:25:10 +07:00
committed by GitHub
parent f98e7fbc49
commit 9ef11bf930
28 changed files with 1325 additions and 489 deletions

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from enum import Enum
from typing import Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@@ -161,3 +162,12 @@ class AppConversationStartTask(BaseModel):
class AppConversationStartTaskPage(BaseModel):
items: list[AppConversationStartTask]
next_page_id: str | None = None
class SkillResponse(BaseModel):
"""Response model for skills endpoint."""
name: str
type: Literal['repo', 'knowledge']
content: str
triggers: list[str] = []

View File

@@ -1,11 +1,12 @@
"""Sandboxed Conversation router for OpenHands Server."""
import asyncio
import logging
import os
import sys
import tempfile
from datetime import datetime
from typing import Annotated, AsyncGenerator
from typing import Annotated, AsyncGenerator, Literal
from uuid import UUID
import httpx
@@ -28,8 +29,8 @@ else:
return await async_iterator.__anext__()
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Query, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -39,10 +40,14 @@ from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTask,
AppConversationStartTaskPage,
AppConversationStartTaskSortOrder,
SkillResponse,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
)
@@ -65,9 +70,11 @@ from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
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
router = APIRouter(prefix='/app-conversations', tags=['Conversations'])
logger = logging.getLogger(__name__)
app_conversation_service_dependency = depends_app_conversation_service()
app_conversation_start_task_service_dependency = (
depends_app_conversation_start_task_service()
@@ -400,6 +407,145 @@ async def read_conversation_file(
return ''
@router.get('/{conversation_id}/skills')
async def get_conversation_skills(
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,
) -> JSONResponse:
"""Get all skills associated with the conversation.
This endpoint returns all skills that are loaded for the v1 conversation.
Skills are loaded from multiple sources:
- Sandbox skills (exposed URLs)
- Global skills (OpenHands/skills/)
- User skills (~/.openhands/skills/)
- Organization skills (org/.openhands repository)
- Repository skills (repo/.openhands/skills/ or .openhands/microagents/)
Returns:
JSONResponse: A JSON response containing the list of skills.
"""
try:
# 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:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Sandbox spec not found'},
)
# 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)
# Create remote workspace
remote_workspace = AsyncRemoteWorkspace(
host=agent_server_url,
api_key=sandbox.session_api_key,
working_dir=sandbox_spec.working_dir,
)
# Load skills from all sources
logger.info(f'Loading skills for conversation {conversation_id}')
# Prefer the shared loader to avoid duplication; otherwise return empty list.
all_skills: list = []
if isinstance(app_conversation_service, AppConversationServiceBase):
all_skills = await app_conversation_service.load_and_merge_all_skills(
sandbox,
remote_workspace,
conversation.selected_repository,
sandbox_spec.working_dir,
)
logger.info(
f'Loaded {len(all_skills)} skills for conversation {conversation_id}: '
f'{[s.name for s in all_skills]}'
)
# Transform skills to response format
skills_response = []
for skill in all_skills:
# Determine type based on trigger
skill_type: Literal['repo', 'knowledge']
if skill.trigger is None:
skill_type = 'repo'
else:
skill_type = 'knowledge'
# Extract triggers
triggers = []
if isinstance(skill.trigger, (KeywordTrigger, TaskTrigger)):
if hasattr(skill.trigger, 'keywords'):
triggers = skill.trigger.keywords
elif hasattr(skill.trigger, 'triggers'):
triggers = skill.trigger.triggers
skills_response.append(
SkillResponse(
name=skill.name,
type=skill_type,
content=skill.content,
triggers=triggers,
)
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'skills': [s.model_dump() for s in skills_response]},
)
except Exception as e:
logger.error(f'Error getting skills for conversation {conversation_id}: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': f'Error getting skills: {str(e)}'},
)
async def _consume_remaining(
async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient
):

View File

@@ -58,7 +58,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
init_git_in_empty_workspace: bool
user_context: UserContext
async def _load_and_merge_all_skills(
async def load_and_merge_all_skills(
self,
sandbox: SandboxInfo,
remote_workspace: AsyncRemoteWorkspace,
@@ -169,7 +169,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
Updated agent with skills loaded into context
"""
# Load and merge all skills
all_skills = await self._load_and_merge_all_skills(
all_skills = await self.load_and_merge_all_skills(
sandbox, remote_workspace, selected_repository, working_dir
)
@@ -198,7 +198,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task
await self._load_and_merge_all_skills(
await self.load_and_merge_all_skills(
sandbox,
workspace,
task.request.selected_repository,