deer-flow/backend/tests/blocking_io/test_skill_manage_tool.py
Hyeonsang Cho b9ee8af328
fix(skills): keep custom-skill edit and storage lookups off the event loop (#6332)
* fix(skills): keep custom-skill edit checks off the event loop

update_custom_skill built the user-scoped storage, ran the editability
probes, validated the frontmatter through a temporary directory and read
the content being replaced on the Gateway loop; only the write and history
append had been offloaded. Run the pre-scan checks in one worker and the
replaced-content read in another, matching rollback_custom_skill, and build
delete_custom_skill's storage off the loop as well.

* fix(skills): build skill storage off the loop in install and skill_manage

The same sweep that moved the custom-skill edit checks off the loop finds
two more storage lookups on it: the archive-install helper behind both
install routes, and the agent's skill_manage tool, whose lookup resolves
and stats config.yaml on every call. Offload both, and pin that the
caller's user context still reaches the storage lookup now that the edit
route resolves it inside a worker.

* docs(changelog): reference #6332 for the skill storage off-loop fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-06 07:20:04 +08:00

65 lines
2.5 KiB
Python

"""Regression anchor: the agent's ``skill_manage`` tool must keep its storage lookup off the loop.
``_skill_manage_impl`` offloads every per-action filesystem step through
``asyncio.to_thread``. The user-scoped storage lookup in front of them resolves
the app config (``get_app_config`` stats ``config.yaml`` on every call) and, on
a cold cache, the project root, so it runs in a worker as well -- the same rule
the Gateway's custom-skill edit route applies.
Only the LLM security scan is stubbed; the app config is pinned so the lookup
needs no ``config.yaml`` on disk, and the edit runs against the real per-user
storage under ``DEER_FLOW_HOME``.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
import deerflow.tools.skill_manage_tool as skill_manage_module
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
pytestmark = pytest.mark.asyncio
_SKILL_NAME = "loop-manage-skill"
_SKILL_MD = f"---\nname: {_SKILL_NAME}\ndescription: Anchor fixture skill.\n---\n\n# {_SKILL_NAME}\n"
_RUNTIME = SimpleNamespace(
context={"thread_id": "thread-1", "user_id": "default"},
config={"configurable": {"thread_id": "thread-1", "user_id": "default"}},
)
@pytest.fixture(autouse=True)
def _isolate_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
def _install_skill() -> Path:
skill_dir = get_paths().user_custom_skills_dir("default") / _SKILL_NAME
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(_SKILL_MD, encoding="utf-8")
return skill_file
async def test_skill_manage_edit_does_not_block_event_loop(monkeypatch) -> None:
skill_file = await asyncio.to_thread(_install_skill)
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
async def _allow_scan(*args, **kwargs):
return SimpleNamespace(decision="allow", reason="anchor stub")
monkeypatch.setattr(skill_manage_module, "scan_skill_content", _allow_scan)
edited = _SKILL_MD + "\nEdited.\n"
result = await skill_manage_module.skill_manage_tool.coroutine(_RUNTIME, "edit", _SKILL_NAME, edited)
assert result == f"Updated custom skill '{_SKILL_NAME}'."
assert await asyncio.to_thread(skill_file.read_text, encoding="utf-8") == edited