feat(F): F-CTX context_builder, F-STR agent_stream, F-DEF agent definition fields, F-SKILL skill_registry, F-TOOL agent_tools
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- F-CTX: app/ai/context_builder.py (282 lines) — build_agent_context() + ReActSystemPromptBuilder - F-STR: app/ai/agent_stream.py (155 lines) — stream_react_loop() with SSE events (step, status, done, error) - F-DEF: AgentDefinition fields added (temperature, max_tokens, max_steps, trace_mode, skill_ids, trigger_config, ai_use_case_metadata) + migration 0122 - F-SKILL: app/ai/skill_registry.py (82 lines) — SkillDefinition + SkillRegistry singleton - F-TOOL: app/ai/agent_tools.py (117 lines) — get_agent_tools() with permission intersection - Skill CRUD routes: app/plugins/builtins/automation/skill_routes.py - Tests: test_skill_registry.py (97 lines), test_agent_tools.py (219 lines) - All Python compile checks pass, tests require PostgreSQL (infra issue, not code bug)
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""API routes for skill definitions — /api/v1/skills.
|
||||
|
||||
Endpoints: skill definitions CRUD. Skills are orchestration metadata, NOT a
|
||||
permission source: they reference tool IDs that the agent and the user must
|
||||
already be permitted to use.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.automation.models import SkillDefinitionDB
|
||||
from app.plugins.builtins.automation.schemas import (
|
||||
SkillDefinitionCreate,
|
||||
SkillDefinitionListResponse,
|
||||
SkillDefinitionResponse,
|
||||
SkillDefinitionUpdate,
|
||||
)
|
||||
from app.plugins.builtins.automation.services import SkillService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/skills", tags=["skills"])
|
||||
|
||||
|
||||
# ─── Helper Functions ───
|
||||
|
||||
|
||||
def _skill_to_response(s: SkillDefinitionDB) -> SkillDefinitionResponse:
|
||||
"""Convert SkillDefinitionDB model to response schema."""
|
||||
return SkillDefinitionResponse(
|
||||
id=str(s.id),
|
||||
name=s.name,
|
||||
description=s.description or "",
|
||||
instructions=s.instructions or "",
|
||||
allowed_tool_ids=[str(t) for t in (s.allowed_tool_ids or [])],
|
||||
context_policy=s.context_policy,
|
||||
category=s.category or "general",
|
||||
is_active=s.is_active,
|
||||
created_at=s.created_at.isoformat() if s.created_at else None,
|
||||
updated_at=s.updated_at.isoformat() if s.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
# ─── CRUD Endpoints ───
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
dependencies=[Depends(require_permission("automation:read"))],
|
||||
response_model=SkillDefinitionListResponse,
|
||||
)
|
||||
async def list_skills(
|
||||
is_active: bool | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List skill definitions with optional filters."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
items, total = await SkillService.list(
|
||||
db, tenant_id, is_active=is_active, category=category, limit=limit, offset=offset
|
||||
)
|
||||
return SkillDefinitionListResponse(
|
||||
items=[_skill_to_response(s) for s in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
dependencies=[Depends(require_permission("automation:write"))],
|
||||
response_model=SkillDefinitionResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_skill(
|
||||
data: SkillDefinitionCreate,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new skill definition."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
skill = await SkillService.create(db, tenant_id, data.model_dump())
|
||||
return _skill_to_response(skill)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{skill_id}",
|
||||
dependencies=[Depends(require_permission("automation:read"))],
|
||||
response_model=SkillDefinitionResponse,
|
||||
)
|
||||
async def get_skill(
|
||||
skill_id: str,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single skill definition by ID."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(skill_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid skill ID") from None
|
||||
|
||||
skill = await SkillService.get_by_id(db, tenant_id, sid)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
return _skill_to_response(skill)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{skill_id}",
|
||||
dependencies=[Depends(require_permission("automation:write"))],
|
||||
response_model=SkillDefinitionResponse,
|
||||
)
|
||||
async def update_skill(
|
||||
skill_id: str,
|
||||
data: SkillDefinitionUpdate,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an existing skill definition."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(skill_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid skill ID") from None
|
||||
|
||||
skill = await SkillService.update(
|
||||
db, tenant_id, sid, data.model_dump(exclude_none=True)
|
||||
)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
return _skill_to_response(skill)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{skill_id}",
|
||||
dependencies=[Depends(require_permission("automation:delete"))],
|
||||
)
|
||||
async def delete_skill(
|
||||
skill_id: str,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a skill definition."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(skill_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid skill ID") from None
|
||||
|
||||
success = await SkillService.delete(db, tenant_id, sid)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user