mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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
391 lines
13 KiB
Python
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
|