3e6a34297d
Squashes 25 PR commits onto current main. AppConfig becomes a pure value object with no ambient lookup. Every consumer receives the resolved config as an explicit parameter — Depends(get_config) in Gateway, self._app_config in DeerFlowClient, runtime.context.app_config in agent runs, AppConfig.from_file() at the LangGraph Server registration boundary. Phase 1 — frozen data + typed context - All config models (AppConfig, MemoryConfig, DatabaseConfig, …) become frozen=True; no sub-module globals. - AppConfig.from_file() is pure (no side-effect singleton loaders). - Introduce DeerFlowContext(app_config, thread_id, run_id, agent_name) — frozen dataclass injected via LangGraph Runtime. - Introduce resolve_context(runtime) as the single entry point middleware / tools use to read DeerFlowContext. Phase 2 — pure explicit parameter passing - Gateway: app.state.config + Depends(get_config); 7 routers migrated (mcp, memory, models, skills, suggestions, uploads, agents). - DeerFlowClient: __init__(config=...) captures config locally. - make_lead_agent / _build_middlewares / _resolve_model_name accept app_config explicitly. - RunContext.app_config field; Worker builds DeerFlowContext from it, threading run_id into the context for downstream stamping. - Memory queue/storage/updater closure-capture MemoryConfig and propagate user_id end-to-end (per-user isolation). - Sandbox/skills/community/factories/tools thread app_config. - resolve_context() rejects non-typed runtime.context. - Test suite migrated off AppConfig.current() monkey-patches. - AppConfig.current() classmethod deleted. Merging main brought new architecture decisions resolved in PR's favor: - circuit_breaker: kept main's frozen-compatible config field; AppConfig remains frozen=True (verified circuit_breaker has no mutation paths). - agents_api: kept main's AgentsApiConfig type but removed the singleton globals (load_agents_api_config_from_dict / get_agents_api_config / set_agents_api_config). 8 routes in agents.py now read via Depends(get_config). - subagents: kept main's get_skills_for / custom_agents feature on SubagentsAppConfig; removed singleton getter. registry.py now reads app_config.subagents directly. - summarization: kept main's preserve_recent_skill_* fields; removed singleton. - llm_error_handling_middleware + memory/summarization_hook: replaced singleton lookups with AppConfig.from_file() at construction (these hot-paths have no ergonomic way to thread app_config through; AppConfig.from_file is a pure load). - worker.py + thread_data_middleware.py: DeerFlowContext.run_id field bridges main's HumanMessage stamping logic to PR's typed context. Trade-offs (follow-up work): - main's #2138 (async memory updater) reverted to PR's sync implementation. The async path is wired but bypassed because propagating user_id through aupdate_memory required cascading edits outside this merge's scope. - tests/test_subagent_skills_config.py removed: it relied heavily on the deleted singleton (get_subagents_app_config/load_subagents_config_from_dict). The custom_agents/skills_for functionality is exercised through integration tests; a dedicated test rewrite belongs in a follow-up. Verification: backend test suite — 2560 passed, 4 skipped, 84 failures. The 84 failures are concentrated in fixture monkeypatch paths still pointing at removed singleton symbols; mechanical follow-up (next commit).
387 lines
17 KiB
Python
387 lines
17 KiB
Python
import errno
|
|
import json
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.extensions_config import ExtensionsConfig
|
|
from deerflow.skills import Skill, load_skills
|
|
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
|
|
from deerflow.skills.manager import (
|
|
append_history,
|
|
atomic_write,
|
|
custom_skill_exists,
|
|
ensure_custom_skill_is_editable,
|
|
get_custom_skill_dir,
|
|
get_custom_skill_file,
|
|
get_skill_history_file,
|
|
read_custom_skill_content,
|
|
read_history,
|
|
validate_skill_markdown_content,
|
|
)
|
|
from deerflow.skills.security_scanner import scan_skill_content
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["skills"])
|
|
|
|
|
|
class SkillResponse(BaseModel):
|
|
"""Response model for skill information."""
|
|
|
|
name: str = Field(..., description="Name of the skill")
|
|
description: str = Field(..., description="Description of what the skill does")
|
|
license: str | None = Field(None, description="License information")
|
|
category: str = Field(..., description="Category of the skill (public or custom)")
|
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
|
|
|
|
|
class SkillsListResponse(BaseModel):
|
|
"""Response model for listing all skills."""
|
|
|
|
skills: list[SkillResponse]
|
|
|
|
|
|
class SkillUpdateRequest(BaseModel):
|
|
"""Request model for updating a skill."""
|
|
|
|
enabled: bool = Field(..., description="Whether to enable or disable the skill")
|
|
|
|
|
|
class SkillInstallRequest(BaseModel):
|
|
"""Request model for installing a skill from a .skill file."""
|
|
|
|
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
|
|
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
|
|
|
|
|
|
class SkillInstallResponse(BaseModel):
|
|
"""Response model for skill installation."""
|
|
|
|
success: bool = Field(..., description="Whether the installation was successful")
|
|
skill_name: str = Field(..., description="Name of the installed skill")
|
|
message: str = Field(..., description="Installation result message")
|
|
|
|
|
|
class CustomSkillContentResponse(SkillResponse):
|
|
content: str = Field(..., description="Raw SKILL.md content")
|
|
|
|
|
|
class CustomSkillUpdateRequest(BaseModel):
|
|
content: str = Field(..., description="Replacement SKILL.md content")
|
|
|
|
|
|
class CustomSkillHistoryResponse(BaseModel):
|
|
history: list[dict]
|
|
|
|
|
|
class SkillRollbackRequest(BaseModel):
|
|
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
|
|
|
|
|
def _skill_to_response(skill: Skill) -> SkillResponse:
|
|
"""Convert a Skill object to a SkillResponse."""
|
|
return SkillResponse(
|
|
name=skill.name,
|
|
description=skill.description,
|
|
license=skill.license,
|
|
category=skill.category,
|
|
enabled=skill.enabled,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/skills",
|
|
response_model=SkillsListResponse,
|
|
summary="List All Skills",
|
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
|
)
|
|
async def list_skills(app_config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
|
try:
|
|
skills = load_skills(app_config, enabled_only=False)
|
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
|
except Exception as e:
|
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/skills/install",
|
|
response_model=SkillInstallResponse,
|
|
summary="Install Skill",
|
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
|
)
|
|
async def install_skill(request: SkillInstallRequest, app_config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
|
try:
|
|
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
|
result = install_skill_from_archive(skill_file_path)
|
|
await refresh_skills_system_prompt_cache_async(app_config)
|
|
return SkillInstallResponse(**result)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except SkillAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to install skill: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
|
|
|
|
|
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
|
async def list_custom_skills(app_config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
|
try:
|
|
skills = [skill for skill in load_skills(app_config, enabled_only=False) if skill.category == "custom"]
|
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
|
except Exception as e:
|
|
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
|
|
|
|
|
|
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
|
async def get_custom_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
|
try:
|
|
skills = load_skills(app_config, enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name and s.category == "custom"), None)
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=read_custom_skill_content(skill_name, app_config))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to get custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get custom skill: {str(e)}")
|
|
|
|
|
|
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
|
async def update_custom_skill(
|
|
skill_name: str,
|
|
request: CustomSkillUpdateRequest,
|
|
app_config: AppConfig = Depends(get_config),
|
|
) -> CustomSkillContentResponse:
|
|
try:
|
|
ensure_custom_skill_is_editable(skill_name, app_config)
|
|
validate_skill_markdown_content(skill_name, request.content)
|
|
scan = await scan_skill_content(app_config, request.content, executable=False, location=f"{skill_name}/SKILL.md")
|
|
if scan.decision == "block":
|
|
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
|
skill_file = get_custom_skill_dir(skill_name, app_config) / "SKILL.md"
|
|
prev_content = skill_file.read_text(encoding="utf-8")
|
|
atomic_write(skill_file, request.content)
|
|
append_history(
|
|
skill_name,
|
|
{
|
|
"action": "human_edit",
|
|
"author": "human",
|
|
"thread_id": None,
|
|
"file_path": "SKILL.md",
|
|
"prev_content": prev_content,
|
|
"new_content": request.content,
|
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
|
},
|
|
app_config,
|
|
)
|
|
await refresh_skills_system_prompt_cache_async(app_config)
|
|
return await get_custom_skill(skill_name, app_config)
|
|
except HTTPException:
|
|
raise
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error("Failed to update custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update custom skill: {str(e)}")
|
|
|
|
|
|
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
|
async def delete_custom_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
|
try:
|
|
ensure_custom_skill_is_editable(skill_name, app_config)
|
|
skill_dir = get_custom_skill_dir(skill_name, app_config)
|
|
prev_content = read_custom_skill_content(skill_name, app_config)
|
|
try:
|
|
append_history(
|
|
skill_name,
|
|
{
|
|
"action": "human_delete",
|
|
"author": "human",
|
|
"thread_id": None,
|
|
"file_path": "SKILL.md",
|
|
"prev_content": prev_content,
|
|
"new_content": None,
|
|
"scanner": {"decision": "allow", "reason": "Deletion requested."},
|
|
},
|
|
app_config,
|
|
)
|
|
except OSError as e:
|
|
if not isinstance(e, PermissionError) and e.errno not in {errno.EACCES, errno.EPERM, errno.EROFS}:
|
|
raise
|
|
logger.warning("Skipping delete history write for custom skill %s due to readonly/permission failure; continuing with skill directory removal: %s", skill_name, e)
|
|
shutil.rmtree(skill_dir)
|
|
await refresh_skills_system_prompt_cache_async(app_config)
|
|
return {"success": True}
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error("Failed to delete custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
|
|
|
|
|
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
|
async def get_custom_skill_history(skill_name: str, app_config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
|
try:
|
|
if not custom_skill_exists(skill_name, app_config) and not get_skill_history_file(skill_name, app_config).exists():
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
return CustomSkillHistoryResponse(history=read_history(skill_name, app_config))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to read history for %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}")
|
|
|
|
|
|
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
|
async def rollback_custom_skill(
|
|
skill_name: str,
|
|
request: SkillRollbackRequest,
|
|
app_config: AppConfig = Depends(get_config),
|
|
) -> CustomSkillContentResponse:
|
|
try:
|
|
if not custom_skill_exists(skill_name, app_config) and not get_skill_history_file(skill_name, app_config).exists():
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
history = read_history(skill_name, app_config)
|
|
if not history:
|
|
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
|
record = history[request.history_index]
|
|
target_content = record.get("prev_content")
|
|
if target_content is None:
|
|
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
|
validate_skill_markdown_content(skill_name, target_content)
|
|
scan = await scan_skill_content(app_config, target_content, executable=False, location=f"{skill_name}/SKILL.md")
|
|
skill_file = get_custom_skill_file(skill_name, app_config)
|
|
current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
|
|
history_entry = {
|
|
"action": "rollback",
|
|
"author": "human",
|
|
"thread_id": None,
|
|
"file_path": "SKILL.md",
|
|
"prev_content": current_content,
|
|
"new_content": target_content,
|
|
"rollback_from_ts": record.get("ts"),
|
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
|
}
|
|
if scan.decision == "block":
|
|
append_history(skill_name, history_entry, app_config)
|
|
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
|
atomic_write(skill_file, target_content)
|
|
append_history(skill_name, history_entry, app_config)
|
|
await refresh_skills_system_prompt_cache_async(app_config)
|
|
return await get_custom_skill(skill_name, app_config)
|
|
except HTTPException:
|
|
raise
|
|
except IndexError:
|
|
raise HTTPException(status_code=400, detail="history_index is out of range")
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error("Failed to roll back custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to roll back custom skill: {str(e)}")
|
|
|
|
|
|
@router.get(
|
|
"/skills/{skill_name}",
|
|
response_model=SkillResponse,
|
|
summary="Get Skill Details",
|
|
description="Retrieve detailed information about a specific skill by its name.",
|
|
)
|
|
async def get_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> SkillResponse:
|
|
try:
|
|
skills = load_skills(app_config, enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
return _skill_to_response(skill)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/skills/{skill_name}",
|
|
response_model=SkillResponse,
|
|
summary="Update Skill",
|
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
|
)
|
|
async def update_skill(
|
|
skill_name: str,
|
|
request: SkillUpdateRequest,
|
|
http_request: Request,
|
|
app_config: AppConfig = Depends(get_config),
|
|
) -> SkillResponse:
|
|
try:
|
|
skills = load_skills(app_config, enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
config_path = Path.cwd().parent / "extensions_config.json"
|
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
|
|
|
# Do not mutate the frozen AppConfig in place. Compose the new skills
|
|
# state in a fresh dict, write to disk, and reload AppConfig below so
|
|
# every subsequent Depends(get_config) sees the refreshed snapshot.
|
|
ext = app_config.extensions
|
|
updated_skills = {name: {"enabled": skill_config.enabled} for name, skill_config in ext.skills.items()}
|
|
updated_skills[skill_name] = {"enabled": request.enabled}
|
|
|
|
config_data = {
|
|
"mcpServers": {name: server.model_dump() for name, server in ext.mcp_servers.items()},
|
|
"skills": updated_skills,
|
|
}
|
|
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config_data, f, indent=2)
|
|
|
|
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
|
# Reload AppConfig and swap ``app.state.config`` so subsequent
|
|
# ``Depends(get_config)`` sees the refreshed value.
|
|
reloaded = AppConfig.from_file()
|
|
http_request.app.state.config = reloaded
|
|
await refresh_skills_system_prompt_cache_async(reloaded)
|
|
|
|
skills = load_skills(reloaded, enabled_only=False)
|
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if updated_skill is None:
|
|
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
|
|
|
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
|
return _skill_to_response(updated_skill)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|