diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index 089e02761..d0191743d 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.gateway.path_utils import resolve_thread_virtual_path -from deerflow.agents.lead_agent.prompt import clear_skills_system_prompt_cache +from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config from deerflow.skills import Skill, load_skills from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive @@ -119,6 +119,7 @@ async def install_skill(request: SkillInstallRequest) -> 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() return SkillInstallResponse(**result) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -181,7 +182,7 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest "scanner": {"decision": scan.decision, "reason": scan.reason}, }, ) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return await get_custom_skill(skill_name) except HTTPException: raise @@ -213,7 +214,7 @@ async def delete_custom_skill(skill_name: str) -> dict[str, bool]: }, ) shutil.rmtree(skill_dir) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return {"success": True} except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -268,7 +269,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) 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) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return await get_custom_skill(skill_name) except HTTPException: raise @@ -337,6 +338,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillRes logger.info(f"Skills configuration updated and saved to: {config_path}") reload_extensions_config() + await refresh_skills_system_prompt_cache_async() skills = load_skills(enabled_only=False) updated_skill = next((s for s in skills if s.name == skill_name), None) diff --git a/backend/packages/harness/deerflow/agents/__init__.py b/backend/packages/harness/deerflow/agents/__init__.py index 32f300004..2c31a514a 100644 --- a/backend/packages/harness/deerflow/agents/__init__.py +++ b/backend/packages/harness/deerflow/agents/__init__.py @@ -2,8 +2,14 @@ from .checkpointer import get_checkpointer, make_checkpointer, reset_checkpointe from .factory import create_deerflow_agent from .features import Next, Prev, RuntimeFeatures from .lead_agent import make_lead_agent +from .lead_agent.prompt import prime_enabled_skills_cache from .thread_state import SandboxState, ThreadState +# LangGraph imports deerflow.agents when registering the graph. Prime the +# enabled-skills cache here so the request path can usually read a warm cache +# without forcing synchronous filesystem work during prompt module import. +prime_enabled_skills_cache() + __all__ = [ "create_deerflow_agent", "RuntimeFeatures", diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index c41037d7c..4aa2141e1 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -1,20 +1,114 @@ +import asyncio import logging +import threading from datetime import datetime from functools import lru_cache from deerflow.config.agents_config import load_agent_soul from deerflow.skills import load_skills +from deerflow.skills.types import Skill from deerflow.subagents import get_available_subagent_names logger = logging.getLogger(__name__) +_ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS = 5.0 +_enabled_skills_lock = threading.Lock() +_enabled_skills_cache: list[Skill] | None = None +_enabled_skills_refresh_active = False +_enabled_skills_refresh_version = 0 +_enabled_skills_refresh_event = threading.Event() + + +def _load_enabled_skills_sync() -> list[Skill]: + return list(load_skills(enabled_only=True)) + + +def _start_enabled_skills_refresh_thread() -> None: + threading.Thread( + target=_refresh_enabled_skills_cache_worker, + name="deerflow-enabled-skills-loader", + daemon=True, + ).start() + + +def _refresh_enabled_skills_cache_worker() -> None: + global _enabled_skills_cache, _enabled_skills_refresh_active + + while True: + with _enabled_skills_lock: + target_version = _enabled_skills_refresh_version + + try: + skills = _load_enabled_skills_sync() + except Exception: + logger.exception("Failed to load enabled skills for prompt injection") + skills = [] + + with _enabled_skills_lock: + if _enabled_skills_refresh_version == target_version: + _enabled_skills_cache = skills + _enabled_skills_refresh_active = False + _enabled_skills_refresh_event.set() + return + + # A newer invalidation happened while loading. Keep the worker alive + # and loop again so the cache always converges on the latest version. + _enabled_skills_cache = None + + +def _ensure_enabled_skills_cache() -> threading.Event: + global _enabled_skills_refresh_active + + with _enabled_skills_lock: + if _enabled_skills_cache is not None: + _enabled_skills_refresh_event.set() + return _enabled_skills_refresh_event + if _enabled_skills_refresh_active: + return _enabled_skills_refresh_event + _enabled_skills_refresh_active = True + _enabled_skills_refresh_event.clear() + + _start_enabled_skills_refresh_thread() + return _enabled_skills_refresh_event + + +def _invalidate_enabled_skills_cache() -> threading.Event: + global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version + + _get_cached_skills_prompt_section.cache_clear() + with _enabled_skills_lock: + _enabled_skills_cache = None + _enabled_skills_refresh_version += 1 + _enabled_skills_refresh_event.clear() + if _enabled_skills_refresh_active: + return _enabled_skills_refresh_event + _enabled_skills_refresh_active = True + + _start_enabled_skills_refresh_thread() + return _enabled_skills_refresh_event + + +def prime_enabled_skills_cache() -> None: + _ensure_enabled_skills_cache() + + +def warm_enabled_skills_cache(timeout_seconds: float = _ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS) -> bool: + if _ensure_enabled_skills_cache().wait(timeout=timeout_seconds): + return True + + logger.warning("Timed out waiting %.1fs for enabled skills cache warm-up", timeout_seconds) + return False + def _get_enabled_skills(): - try: - return list(load_skills(enabled_only=True)) - except Exception: - logger.exception("Failed to load enabled skills for prompt injection") - return [] + with _enabled_skills_lock: + cached = _enabled_skills_cache + + if cached is not None: + return list(cached) + + _ensure_enabled_skills_cache() + return [] def _skill_mutability_label(category: str) -> str: @@ -22,7 +116,36 @@ def _skill_mutability_label(category: str) -> str: def clear_skills_system_prompt_cache() -> None: + _invalidate_enabled_skills_cache() + + +async def refresh_skills_system_prompt_cache_async() -> None: + await asyncio.to_thread(_invalidate_enabled_skills_cache().wait) + + +def _reset_skills_system_prompt_cache_state() -> None: + global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version + _get_cached_skills_prompt_section.cache_clear() + with _enabled_skills_lock: + _enabled_skills_cache = None + _enabled_skills_refresh_active = False + _enabled_skills_refresh_version = 0 + _enabled_skills_refresh_event.clear() + + +def _refresh_enabled_skills_cache() -> None: + """Backward-compatible test helper for direct synchronous reload.""" + try: + skills = _load_enabled_skills_sync() + except Exception: + logger.exception("Failed to load enabled skills for prompt injection") + skills = [] + + with _enabled_skills_lock: + _enabled_skills_cache = skills + _enabled_skills_refresh_active = False + _enabled_skills_refresh_event.set() def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str: diff --git a/backend/packages/harness/deerflow/tools/skill_manage_tool.py b/backend/packages/harness/deerflow/tools/skill_manage_tool.py index 64fa884f0..3b7a109cc 100644 --- a/backend/packages/harness/deerflow/tools/skill_manage_tool.py +++ b/backend/packages/harness/deerflow/tools/skill_manage_tool.py @@ -11,7 +11,7 @@ from weakref import WeakValueDictionary from langchain.tools import ToolRuntime, tool from langgraph.typing import ContextT -from deerflow.agents.lead_agent.prompt import clear_skills_system_prompt_cache +from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.thread_state import ThreadState from deerflow.mcp.tools import _make_sync_tool_wrapper from deerflow.skills.manager import ( @@ -115,7 +115,7 @@ async def _skill_manage_impl( name, _history_record(action="create", file_path="SKILL.md", prev_content=None, new_content=content, thread_id=thread_id, scanner=scan), ) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return f"Created custom skill '{name}'." if action == "edit": @@ -132,7 +132,7 @@ async def _skill_manage_impl( name, _history_record(action="edit", file_path="SKILL.md", prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan), ) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return f"Updated custom skill '{name}'." if action == "patch": @@ -156,7 +156,7 @@ async def _skill_manage_impl( name, _history_record(action="patch", file_path="SKILL.md", prev_content=prev_content, new_content=new_content, thread_id=thread_id, scanner=scan), ) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)." if action == "delete": @@ -169,7 +169,7 @@ async def _skill_manage_impl( _history_record(action="delete", file_path="SKILL.md", prev_content=prev_content, new_content=None, thread_id=thread_id, scanner={"decision": "allow", "reason": "Deletion requested."}), ) await _to_thread(shutil.rmtree, skill_dir) - clear_skills_system_prompt_cache() + await refresh_skills_system_prompt_cache_async() return f"Deleted custom skill '{name}'." if action == "write_file": diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py index ee85a2e91..4962e1d8d 100644 --- a/backend/tests/test_lead_agent_prompt.py +++ b/backend/tests/test_lead_agent_prompt.py @@ -1,6 +1,10 @@ +import threading from types import SimpleNamespace +import anyio + from deerflow.agents.lead_agent import prompt as prompt_module +from deerflow.skills.types import Skill def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch): @@ -34,7 +38,7 @@ def test_apply_prompt_template_includes_custom_mounts(monkeypatch): skills=SimpleNamespace(container_path="/mnt/skills"), ) monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr(prompt_module, "load_skills", lambda enabled_only=True: []) + monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda: []) monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda: "") monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "") monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "") @@ -44,3 +48,100 @@ def test_apply_prompt_template_includes_custom_mounts(monkeypatch): assert "`/home/user/shared`" in prompt assert "Custom Mounted Directories" in prompt + + +def test_refresh_skills_system_prompt_cache_async_reloads_immediately(monkeypatch, tmp_path): + def make_skill(name: str) -> Skill: + skill_dir = tmp_path / name + return Skill( + name=name, + description=f"Description for {name}", + license="MIT", + skill_dir=skill_dir, + skill_file=skill_dir / "SKILL.md", + relative_path=skill_dir.relative_to(tmp_path), + category="custom", + enabled=True, + ) + + state = {"skills": [make_skill("first-skill")]} + monkeypatch.setattr(prompt_module, "load_skills", lambda enabled_only=True: list(state["skills"])) + prompt_module._reset_skills_system_prompt_cache_state() + + try: + prompt_module.warm_enabled_skills_cache() + assert [skill.name for skill in prompt_module._get_enabled_skills()] == ["first-skill"] + + state["skills"] = [make_skill("second-skill")] + anyio.run(prompt_module.refresh_skills_system_prompt_cache_async) + + assert [skill.name for skill in prompt_module._get_enabled_skills()] == ["second-skill"] + finally: + prompt_module._reset_skills_system_prompt_cache_state() + + +def test_clear_cache_does_not_spawn_parallel_refresh_workers(monkeypatch, tmp_path): + started = threading.Event() + release = threading.Event() + active_loads = 0 + max_active_loads = 0 + call_count = 0 + lock = threading.Lock() + + def make_skill(name: str) -> Skill: + skill_dir = tmp_path / name + return Skill( + name=name, + description=f"Description for {name}", + license="MIT", + skill_dir=skill_dir, + skill_file=skill_dir / "SKILL.md", + relative_path=skill_dir.relative_to(tmp_path), + category="custom", + enabled=True, + ) + + def fake_load_skills(enabled_only=True): + nonlocal active_loads, max_active_loads, call_count + with lock: + active_loads += 1 + max_active_loads = max(max_active_loads, active_loads) + call_count += 1 + current_call = call_count + + started.set() + if current_call == 1: + release.wait(timeout=5) + + with lock: + active_loads -= 1 + + return [make_skill(f"skill-{current_call}")] + + monkeypatch.setattr(prompt_module, "load_skills", fake_load_skills) + prompt_module._reset_skills_system_prompt_cache_state() + + try: + prompt_module.clear_skills_system_prompt_cache() + assert started.wait(timeout=5) + + prompt_module.clear_skills_system_prompt_cache() + release.set() + prompt_module.warm_enabled_skills_cache() + + assert max_active_loads == 1 + assert [skill.name for skill in prompt_module._get_enabled_skills()] == ["skill-2"] + finally: + release.set() + prompt_module._reset_skills_system_prompt_cache_state() + + +def test_warm_enabled_skills_cache_logs_on_timeout(monkeypatch, caplog): + event = threading.Event() + monkeypatch.setattr(prompt_module, "_ensure_enabled_skills_cache", lambda: event) + + with caplog.at_level("WARNING"): + warmed = prompt_module.warm_enabled_skills_cache(timeout_seconds=0.01) + + assert warmed is False + assert "Timed out waiting" in caplog.text diff --git a/backend/tests/test_lead_agent_skills.py b/backend/tests/test_lead_agent_skills.py index f3e0cd927..441dbeee2 100644 --- a/backend/tests/test_lead_agent_skills.py +++ b/backend/tests/test_lead_agent_skills.py @@ -21,7 +21,7 @@ def _make_skill(name: str) -> Skill: def test_get_skills_prompt_section_returns_empty_when_no_skills_match(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) result = get_skills_prompt_section(available_skills={"non_existent_skill"}) assert result == "" @@ -29,7 +29,7 @@ def test_get_skills_prompt_section_returns_empty_when_no_skills_match(monkeypatc def test_get_skills_prompt_section_returns_empty_when_available_skills_empty(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) result = get_skills_prompt_section(available_skills=set()) assert result == "" @@ -37,7 +37,7 @@ def test_get_skills_prompt_section_returns_empty_when_available_skills_empty(mon def test_get_skills_prompt_section_returns_skills(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) result = get_skills_prompt_section(available_skills={"skill1"}) assert "skill1" in result @@ -47,7 +47,7 @@ def test_get_skills_prompt_section_returns_skills(monkeypatch): def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) result = get_skills_prompt_section(available_skills=None) assert "skill1" in result @@ -56,7 +56,7 @@ def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(mon def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch): skills = [_make_skill("skill1")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) monkeypatch.setattr( "deerflow.config.get_app_config", lambda: SimpleNamespace( @@ -70,7 +70,7 @@ def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch): def test_get_skills_prompt_section_includes_self_evolution_rules_without_skills(monkeypatch): - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: []) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: []) monkeypatch.setattr( "deerflow.config.get_app_config", lambda: SimpleNamespace( @@ -85,7 +85,7 @@ def test_get_skills_prompt_section_includes_self_evolution_rules_without_skills( def test_get_skills_prompt_section_cache_respects_skill_evolution_toggle(monkeypatch): skills = [_make_skill("skill1")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt.load_skills", lambda enabled_only: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) config = SimpleNamespace( skills=SimpleNamespace(container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True), diff --git a/backend/tests/test_skill_manage_tool.py b/backend/tests/test_skill_manage_tool.py index 5538a1753..1b16fb48f 100644 --- a/backend/tests/test_skill_manage_tool.py +++ b/backend/tests/test_skill_manage_tool.py @@ -26,7 +26,12 @@ def test_skill_manage_create_and_patch(monkeypatch, tmp_path): monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) - monkeypatch.setattr(skill_manage_module, "clear_skills_system_prompt_cache", lambda: None) + refresh_calls = [] + + async def _refresh(): + refresh_calls.append("refresh") + + monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) runtime = SimpleNamespace(context={"thread_id": "thread-1"}, config={"configurable": {"thread_id": "thread-1"}}) @@ -53,6 +58,7 @@ def test_skill_manage_create_and_patch(monkeypatch, tmp_path): ) assert "Patched custom skill" in patch_result assert "Patched skill" in (skills_root / "custom" / "demo-skill" / "SKILL.md").read_text(encoding="utf-8") + assert refresh_calls == ["refresh", "refresh"] def test_skill_manage_patch_replaces_single_occurrence_by_default(monkeypatch, tmp_path): @@ -64,7 +70,11 @@ def test_skill_manage_patch_replaces_single_occurrence_by_default(monkeypatch, t monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) - monkeypatch.setattr(skill_manage_module, "clear_skills_system_prompt_cache", lambda: None) + + async def _refresh(): + return None + + monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) runtime = SimpleNamespace(context={"thread_id": "thread-1"}, config={"configurable": {"thread_id": "thread-1"}}) @@ -123,7 +133,12 @@ def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path): ) monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) - monkeypatch.setattr(skill_manage_module, "clear_skills_system_prompt_cache", lambda: None) + refresh_calls = [] + + async def _refresh(): + refresh_calls.append("refresh") + + monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) runtime = SimpleNamespace(context={"thread_id": "thread-sync"}, config={"configurable": {"thread_id": "thread-sync"}}) @@ -135,6 +150,7 @@ def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path): ) assert "Created custom skill" in result + assert refresh_calls == ["refresh"] def test_skill_manage_rejects_support_path_traversal(monkeypatch, tmp_path): @@ -146,7 +162,11 @@ def test_skill_manage_rejects_support_path_traversal(monkeypatch, tmp_path): monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) - monkeypatch.setattr(skill_manage_module, "clear_skills_system_prompt_cache", lambda: None) + + async def _refresh(): + return None + + monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) runtime = SimpleNamespace(context={"thread_id": "thread-1"}, config={"configurable": {"thread_id": "thread-1"}}) diff --git a/backend/tests/test_skills_custom_router.py b/backend/tests/test_skills_custom_router.py index cff965da6..3dbcceeda 100644 --- a/backend/tests/test_skills_custom_router.py +++ b/backend/tests/test_skills_custom_router.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from types import SimpleNamespace from fastapi import FastAPI @@ -6,6 +7,7 @@ from fastapi.testclient import TestClient from app.gateway.routers import skills as skills_router from deerflow.skills.manager import get_skill_history_file +from deerflow.skills.types import Skill def _skill_content(name: str, description: str = "Demo skill") -> str: @@ -18,6 +20,20 @@ async def _async_scan(decision: str, reason: str): return ScanResult(decision=decision, reason=reason) +def _make_skill(name: str, *, enabled: bool) -> Skill: + skill_dir = Path(f"/tmp/{name}") + return Skill( + name=name, + description=f"Description for {name}", + license="MIT", + skill_dir=skill_dir, + skill_file=skill_dir / "SKILL.md", + relative_path=Path(name), + category="public", + enabled=enabled, + ) + + def test_custom_skills_router_lifecycle(monkeypatch, tmp_path): skills_root = tmp_path / "skills" custom_dir = skills_root / "custom" / "demo-skill" @@ -30,7 +46,12 @@ def test_custom_skills_router_lifecycle(monkeypatch, tmp_path): monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok")) - monkeypatch.setattr("app.gateway.routers.skills.clear_skills_system_prompt_cache", lambda: None) + refresh_calls = [] + + async def _refresh(): + refresh_calls.append("refresh") + + monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) app = FastAPI() app.include_router(skills_router.router) @@ -58,6 +79,7 @@ def test_custom_skills_router_lifecycle(monkeypatch, tmp_path): rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1}) assert rollback_response.status_code == 200 assert rollback_response.json()["description"] == "Demo skill" + assert refresh_calls == ["refresh", "refresh"] def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path): @@ -77,7 +99,11 @@ def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path): '{"action":"human_edit","prev_content":' + json.dumps(original_content) + ',"new_content":' + json.dumps(edited_content) + "}\n", encoding="utf-8", ) - monkeypatch.setattr("app.gateway.routers.skills.clear_skills_system_prompt_cache", lambda: None) + + async def _refresh(): + return None + + monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) async def _scan(*args, **kwargs): from deerflow.skills.security_scanner import ScanResult @@ -112,7 +138,12 @@ def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, t monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok")) - monkeypatch.setattr("app.gateway.routers.skills.clear_skills_system_prompt_cache", lambda: None) + refresh_calls = [] + + async def _refresh(): + refresh_calls.append("refresh") + + monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) app = FastAPI() app.include_router(skills_router.router) @@ -130,3 +161,37 @@ def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, t assert rollback_response.status_code == 200 assert rollback_response.json()["description"] == "Demo skill" assert (custom_dir / "SKILL.md").read_text(encoding="utf-8") == original_content + assert refresh_calls == ["refresh", "refresh"] + + +def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path): + config_path = tmp_path / "extensions_config.json" + enabled_state = {"value": True} + refresh_calls = [] + + def _load_skills(*, enabled_only: bool): + skill = _make_skill("demo-skill", enabled=enabled_state["value"]) + if enabled_only and not skill.enabled: + return [] + return [skill] + + async def _refresh(): + refresh_calls.append("refresh") + enabled_state["value"] = False + + monkeypatch.setattr("app.gateway.routers.skills.load_skills", _load_skills) + monkeypatch.setattr("app.gateway.routers.skills.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={})) + monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None) + monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path)) + monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) + + app = FastAPI() + app.include_router(skills_router.router) + + with TestClient(app) as client: + response = client.put("/api/skills/demo-skill", json={"enabled": False}) + + assert response.status_code == 200 + assert response.json()["enabled"] is False + assert refresh_calls == ["refresh"] + assert json.loads(config_path.read_text(encoding="utf-8")) == {"mcpServers": {}, "skills": {"demo-skill": {"enabled": False}}}