deer-flow/backend/tests/test_skill_policy_deferred_discovery.py
trent87 0d73798beb
feature(skills): keep allowed-tools as compatibility metadata (#6935)
* refactor(skills): keep allowed-tools as compatibility metadata

* docs(skills): align current guides with agent tool authority

* test(skills): simplify authority regressions and sync Korean docs
2026-10-20 19:34:28 +08:00

391 lines
13 KiB
Python

"""Deferred tool/skill discovery stays catalog/hash scoped.
With the Skill tool policy removed, `allowed-tools` never filters
model-visible schemas, execution, or tool_search promotions. These tests pin
the still-supported deferred behaviors: the deferred filter scopes promotion by
catalog hash and the genuine user turn, promoted names come only from the
build-time catalog, the
promotion audit records the final Command, and skill discovery metadata
(describe_skill) stays catalog scoped. Genuine authorization denials are
covered by the authorization suites.
"""
import asyncio
import json
from pathlib import Path
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from pydantic import Field
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
from deerflow.agents.thread_state import ThreadState
from deerflow.runtime.serialization import serialize
from deerflow.skills.describe import build_skill_search_setup
from deerflow.skills.types import Skill, SkillCategory
from deerflow.tools.builtins.tool_search import build_deferred_tool_setup
from deerflow.tools.mcp_metadata import tag_mcp_tool
_SLASH_SOURCE_OWNER_TOKEN = "test-slash-source-owner"
_CALC_CALLS: list[str] = []
class _Recorder:
def __init__(self):
self.calls: list[dict] = []
self.skill_usages: list[dict] = []
def record_middleware(self, **kwargs):
self.calls.append(kwargs)
def record_skill_usage(self, *args, **kwargs):
self.skill_usages.append(args[0] if args else kwargs)
@tool
def calc(expression: str) -> str:
"""Evaluate an arithmetic expression."""
_CALC_CALLS.append(expression)
return "4"
@tool
def other_lookup(query: str) -> str:
"""Another catalog tool."""
return "other data"
class _StorageStub:
def __init__(self, skills: list[Skill]):
self._skills = skills
self.load_calls = 0
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
self.load_calls += 1
return [skill for skill in self._skills if skill.enabled or not enabled_only]
def get_container_root(self) -> str:
return "/mnt/skills"
class _RecordingModel(GenericFakeChatModel):
bound_tool_names: list[list[str]] = Field(default_factory=list)
def __init__(self, responses: list[AIMessage]):
super().__init__(messages=iter(responses))
def bind_tools(self, tools, **kwargs):
self.bound_tool_names.append([getattr(candidate, "name", "") for candidate in tools])
return self
def _skill(name: str, allowed_tools: list[str] | None) -> Skill:
skill_dir = Path(f"/tmp/skills/public/{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=SkillCategory.PUBLIC,
allowed_tools=None if allowed_tools is None else tuple(allowed_tools),
enabled=True,
)
def _deferred_setup():
return build_deferred_tool_setup(
[tag_mcp_tool(calc), tag_mcp_tool(other_lookup)],
enabled=True,
)
class _ActivationStorageStub:
"""Temp-backed storage stub: load_skills plus the path guards the real
SkillActivationMiddleware needs to read SKILL.md from disk."""
def __init__(self, root: Path, skills: list[Skill]):
self._root = root
self._skills = skills
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
return [skill for skill in self._skills if skill.enabled or not enabled_only]
def get_container_root(self) -> str:
return "/mnt/skills"
def get_skills_root_path(self) -> Path:
return self._root
def validate_skill_file_path(self, skill_file: Path) -> Path:
resolved = skill_file.resolve()
root = self._root.resolve()
resolved.relative_to(root) # raises ValueError outside the root
return resolved
def _write_activated_skill(tmp_path: Path) -> Skill:
"""Real SKILL.md with a declared-but-empty allowed-tools sequence."""
skill_dir = tmp_path / "example-safe-skill"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(
"\n".join(
[
"---",
"name: example-safe-skill",
"description: Uses the configured MCP calculator",
"license: MIT",
"allowed-tools: []",
"---",
"",
"# Calculator",
"",
"Use the deferred MCP calculator.",
]
),
encoding="utf-8",
)
return Skill(
name="example-safe-skill",
description="Uses the configured MCP calculator",
license="MIT",
skill_dir=skill_dir,
skill_file=skill_file,
relative_path=Path("example-safe-skill"),
category=SkillCategory.CUSTOM,
allowed_tools=(),
enabled=True,
)
def test_enabled_skills_preserve_deferred_mcp_discovery_and_calling(tmp_path):
"""A real slash activation of a skill whose SKILL.md declares an empty
allowed-tools list still leaves deferred discovery and promotion intact:
tool_search stays bound, `calc` is promoted and executes, and only the
promoted tool becomes visible (allowed-tools is compatibility metadata,
never a filter)."""
skill = _write_activated_skill(tmp_path)
storage = _ActivationStorageStub(tmp_path, [skill])
activation = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
activation._storage = lambda: storage
setup = _deferred_setup()
model = _RecordingModel(
[
AIMessage(
content="",
tool_calls=[
{
"name": "tool_search",
"args": {"query": "select:calc"},
"id": "search-call",
"type": "tool_call",
}
],
),
AIMessage(
content="",
tool_calls=[
{
"name": "calc",
"args": {"expression": "2 + 2"},
"id": "calc-call",
"type": "tool_call",
}
],
),
AIMessage(content="done"),
]
)
_CALC_CALLS.clear()
graph = create_agent(
model=model,
tools=[calc, other_lookup, setup.tool_search_tool],
middleware=[
activation,
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
],
state_schema=ThreadState,
)
recorder = _Recorder()
result = graph.invoke(
{"messages": [HumanMessage(content="/example-safe-skill use the configured MCP calculator", id="msg-1")], "promoted": {"catalog_hash": setup.catalog_hash, "names": []}},
context={"__run_journal": recorder},
config={"recursion_limit": 20},
)
# The slash activation really ran: usage was recorded for the skill.
assert [usage.get("name") for usage in recorder.skill_usages if isinstance(usage, dict)] == ["example-safe-skill"]
assert model.bound_tool_names[0] == ["tool_search"]
assert "calc" in model.bound_tool_names[1]
# Still hidden because it was not promoted — not by any skill declaration.
assert "other_lookup" not in model.bound_tool_names[1]
assert _CALC_CALLS == ["2 + 2"]
assert result["promoted"] == {
"catalog_hash": setup.catalog_hash,
"names": ["calc"],
"turn_id": "msg-1",
}
def test_promotion_is_scoped_to_the_build_time_catalog():
"""tool_search can only promote names present in the deferred catalog; a
query naming a non-deferred tool promotes nothing."""
setup = _deferred_setup()
model = _RecordingModel(
[
AIMessage(
content="",
tool_calls=[
{
"name": "tool_search",
"args": {"query": "select:read_file"},
"id": "search-miss",
"type": "tool_call",
}
],
),
AIMessage(content="done"),
]
)
graph = create_agent(
model=model,
tools=[calc, other_lookup, setup.tool_search_tool],
middleware=[
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
],
state_schema=ThreadState,
)
result = graph.invoke(
{"messages": [HumanMessage(content="search for a non-deferred tool", id="user-catalog-miss")]},
context={},
)
assert result["promoted"] == {
"catalog_hash": setup.catalog_hash,
"names": [],
"turn_id": "user-catalog-miss",
}
assert all("calc" not in names for names in model.bound_tool_names[1:])
def test_promotion_audit_records_final_command_names():
"""The audit middleware observes the final tool_search Command and records
the promoted names exactly once per search."""
setup = _deferred_setup()
recorder = _Recorder()
model = _RecordingModel(
[
AIMessage(
content="",
tool_calls=[
{
"name": "tool_search",
"args": {"query": "select:calc,other_lookup"},
"id": "search-call",
"type": "tool_call",
}
],
),
AIMessage(content="done"),
]
)
graph = create_agent(
model=model,
tools=[calc, other_lookup, setup.tool_search_tool],
middleware=[
DeferredToolPromotionAuditMiddleware(setup.deferred_names, setup.catalog_hash),
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
],
state_schema=ThreadState,
)
asyncio.run(
graph.ainvoke(
{"messages": [HumanMessage(content="promote both tools")]},
context={"__run_journal": recorder},
)
)
assert recorder.calls[0]["changes"]["tool_names"] == ["calc", "other_lookup"]
def test_skill_discovery_metadata_stays_catalog_scoped():
"""describe_skill answers only from its build-time catalog, independent of
any skill's allowed-tools declaration."""
listed = _skill("listed", [])
skill_setup = build_skill_search_setup(
[listed],
enabled=True,
)
model = _RecordingModel(
[
AIMessage(
content="",
tool_calls=[
{
"name": "describe_skill",
"args": {"name": f"select:{listed.name},unknown"},
"id": "describe-call",
"type": "tool_call",
}
],
),
AIMessage(content="done"),
]
)
graph = create_agent(
model=model,
tools=[skill_setup.describe_skill_tool],
middleware=[],
state_schema=ThreadState,
)
result = graph.invoke(
{"messages": [HumanMessage(content="describe available skills")]},
context={},
)
assert model.bound_tool_names[0] == ["describe_skill"]
described = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "describe-call"]
assert len(described) == 1
assert "Skill: listed" in described[0].content
assert "Skill: unknown" not in described[0].content
def test_activation_tokens_do_not_appear_in_debug_or_checkpoint_streams(tmp_path):
"""The slash-source owner token stays out of observable streams even though
the (historical) policy decision key is no longer published."""
skill = _skill("restricted", [])
activation = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
activation._storage = lambda: _StorageStub([skill])
model = _RecordingModel([AIMessage(content="done")])
graph = create_agent(
model=model,
tools=[],
middleware=[activation],
state_schema=ThreadState,
)
events = list(
graph.stream(
{"messages": [HumanMessage(content="finish")]},
context={},
stream_mode=["debug", "checkpoints"],
)
)
serialized = [serialize(chunk, mode=mode) for mode, chunk in events]
payload = json.dumps(serialized, ensure_ascii=False, default=str)
assert events
assert _SLASH_SOURCE_OWNER_TOKEN not in payload
assert "__slash_skill_secret_source" not in payload
assert "__skill_tool_policy_decision" not in payload