deer-flow/backend/tests/blocking_io/test_skill_manage_tool.py
Nan Gao 0550816c50
fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create (#7099)
* fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create

Two leftovers from the multi-instance work (#7097).

The agent's skill_manage tool refreshed only its own process's skills
prompt cache after a create, edit, patch, delete, write_file or
remove_file, so a Gateway replica sharing the home volume kept rendering
that user's previous skill list. Every mutation now runs one drained tail,
as the skill routes do: the write and its history record, the local
refresh, then publish_skills_cache_reset(user_id=...) off the event loop.
A run cancelled while the write is in flight still refreshes and
publishes before the cancellation propagates. A marker that cannot be
written is logged inside the tail and raised as SkillCacheResetPublishError
with a message saying the change was applied, so the model does not retry
a write that succeeded.

Two provisioner replicas starting together against a missing
K8S_NAMESPACE both read 404 and both create; the loser's 409 escaped the
lifespan and crashed it. A 409 on create now counts as the namespace
existing, as the Pod and Service creates already do.

Also drops a stale duplicate of the multi-instance "declaration also
drives" note in CONFIGURATION.md.

* docs(changelog): reference #7099 for the multi-instance leftovers

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 22:28:19 +08:00

97 lines
4.3 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``. With a shared extensions config resolved, the
edit also publishes the cross-replica skills cache reset marker (a lock file
and an atomic replace), which must stay off the loop as well.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
import deerflow.tools.skill_manage_tool as skill_manage_module
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import reset_extensions_config
from deerflow.config.paths import get_paths
from deerflow.config.shared_reset_marker import SharedResetMarkerTracker
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
async def test_skill_manage_edit_publishes_the_shared_marker_off_the_loop(monkeypatch, tmp_path: Path) -> None:
await asyncio.to_thread(_install_skill)
config_path = tmp_path / "extensions_config.json"
await asyncio.to_thread(config_path.write_text, json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
reset_extensions_config()
monkeypatch.setattr(prompt_module, "_skills_cache_reset_tracker", SharedResetMarkerTracker(prompt_module.SKILLS_CACHE_RESET_MARKER))
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)
try:
result = await skill_manage_module.skill_manage_tool.coroutine(_RUNTIME, "edit", _SKILL_NAME, _SKILL_MD + "\nEdited.\n")
finally:
reset_extensions_config()
assert result == f"Updated custom skill '{_SKILL_NAME}'."
marker_path = await asyncio.to_thread(prompt_module.SKILLS_CACHE_RESET_MARKER.path_for, config_path)
payload = json.loads(await asyncio.to_thread(marker_path.read_text, encoding="utf-8"))
assert payload["user_id"] == "default"