feat(skills): deferred skill discovery via describe_skill tool (#3775)

Replace the full-metadata <available_skills> system-prompt block with a
compact <skill_index> (names only) and an on-demand describe_skill tool
when skills.deferred_discovery: true (default: false / backward compat).

New modules:
- skills/catalog.py — SkillCatalog (immutable, searchable; select: has no
  cap, keyword/prefix search caps at MAX_RESULTS=5)
- skills/describe.py — build_describe_skill_tool(catalog) closure;
  build_skill_search_setup() wires SkillSearchSetup into both the
  LangGraph agent factory (agent.py) and DeerFlowClient (client.py)

Changes:
- Skill @dataclass(frozen=True); allowed_tools/required_secrets list→tuple
- Skill First prompt line gated on skill_names (deferred vs legacy wording)
- get_skills_prompt_section: short-circuit storage on deferred path;
  merge user_id (upstream) + skill_names (this PR) params
- describe_skill tool parameter named "name" (matches prompt wording)
- select: branch removes [:MAX_RESULTS] cap (exact request, not ranking)
- AGENTS.md: document deferred_discovery config field + new modules

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Tianye Song 2026-07-04 23:09:29 +08:00 committed by GitHub
parent c22c955c2d
commit 15454b6fec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1122 additions and 57 deletions

View File

@ -404,7 +404,10 @@ Additional providers also live here (`brave`, `browserless`, `crawl4ai`, `ddg_se
- **Location**: `deer-flow/skills/{public,custom}/`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Injection (legacy / default)**: Enabled skills are listed in the agent system prompt with full metadata and container paths (`<available_skills>` block). Controlled by `skills.deferred_discovery: false` (default).
- **Deferred discovery** (`skills.deferred_discovery: true`): Skills are listed by name only in a compact `<skill_index>` block, keeping the system prompt prefix-cache friendly. The agent calls the `describe_skill` tool at runtime to fetch full metadata for skills it wants to use, then loads the SKILL.md via `read_file`. Two new modules support this path:
- `skills/catalog.py``SkillCatalog` (immutable, searchable; query forms: `select:a,b`, `+prefix`, free-text regex); `select:` returns all requested skills without a result cap; other modes cap at `MAX_RESULTS=5`.
- `skills/describe.py``build_describe_skill_tool(catalog)` builds the `describe_skill` tool as a closure; `build_skill_search_setup(skills, enabled, ...)` produces a `SkillSearchSetup(describe_skill_tool, skill_names)` that is wired into both the LangGraph agent factory (`agent.py`) and the embedded client (`client.py`).
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
@ -663,6 +666,7 @@ Returns `{}` when Langfuse is not in the enabled providers — LangSmith-only de
- `tool_groups[]` - Logical groupings for tools
- `sandbox.use` - Sandbox provider class path
- `skills.path` / `skills.container_path` - Host and container paths to skills directory
- `skills.deferred_discovery` - When `true`, replaces the full-metadata `<available_skills>` prompt block with a compact `<skill_index>` (names only) and registers the `describe_skill` tool so the agent fetches metadata on demand. Defaults to `false` (legacy full-metadata injection)
- `title` - Auto-title generation (enabled, max_words, max_chars, model_name; null model_name uses fast local fallback, explicit model_name uses the prompt_template LLM path)
- `summarization` - Context summarization (enabled, trigger conditions, keep policy)
- `subagents.enabled` - Master switch for subagent delegation

View File

@ -520,15 +520,30 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
skills_for_tool_policy = _load_enabled_skills_for_tool_policy(available_skills, app_config=resolved_app_config, user_id=resolved_user_id)
# Build skill search setup (deferred skill discovery).
# Controlled by skills.deferred_discovery — independent from tool_search.enabled.
from deerflow.skills.describe import build_skill_search_setup
skill_search_enabled = resolved_app_config.skills.deferred_discovery
container_base_path = resolved_app_config.skills.container_path
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
# Keep the bootstrap skill set intentionally narrow so agent creation
# remains deterministic before the custom agent's own config exists.
bootstrap_skills = [s for s in skills_for_tool_policy if s.name in _BOOTSTRAP_SKILL_NAMES]
skill_setup = build_skill_search_setup(
bootstrap_skills,
enabled=skill_search_enabled,
container_base_path=container_base_path,
)
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES)
if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,
@ -547,12 +562,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
),
state_schema=ThreadState,
)
# Custom agents can update their own SOUL.md / config via update_agent.
# The default agent (no agent_name) does not see this tool.
# Build skill search setup from policy-filtered skills (same list used for
# tool-policy filtering), so describe_skill only exposes allowed skills.
skill_setup = build_skill_search_setup(
skills_for_tool_policy,
enabled=skill_search_enabled,
container_base_path=container_base_path,
)
#
# Withhold ``update_agent`` from runs triggered by webhook channels
# (currently only ``github``). Webhook prompts come from arbitrary
@ -575,6 +598,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,
@ -595,6 +620,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
),
state_schema=ThreadState,
)

View File

@ -631,8 +631,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
<critical_reminders>
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
- Progressive Loading: Load resources incrementally as referenced in skills
{subagent_reminder}{skill_first_reminder}
- Progressive Loading: Load skill resources incrementally as referenced
- Output Files: Final deliverables must be in `/mnt/user-data/outputs` ( Skills are NOT deliverables use `skill_manage` tool instead)
- File Editing Workflow: When revising an existing file, prefer
`str_replace` over `write_file` it sends only the diff and avoids
@ -750,20 +750,20 @@ You have access to skills that provide optimized workflows for specific tasks. E
</skill_system>"""
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None, user_id: str | None = None) -> str:
"""Generate the skills prompt section with available skills list."""
# Load ALL skills (enabled + disabled) to build the disabled-skills section
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
if user_id:
storage = get_or_new_user_skill_storage(user_id, app_config=app_config)
else:
storage = get_or_new_skill_storage(app_config=app_config)
all_skills = storage.load_skills(enabled_only=False)
disabled_skills = [s for s in all_skills if not s.enabled]
skills = get_enabled_skills_for_config(app_config, user_id=user_id)
def get_skills_prompt_section(
available_skills: set[str] | None = None,
*,
app_config: AppConfig | None = None,
user_id: str | None = None,
skill_names: frozenset[str] | None = None,
) -> str:
"""Generate the skills prompt section.
When *skill_names* is provided, renders a compact ``<skill_index>`` (names
only) so the LLM can discover skills via ``describe_skill``. When omitted,
falls back to the legacy full-metadata ``<available_skills>`` rendering for
backward compatibility.
"""
if app_config is None:
try:
from deerflow.config import get_app_config
@ -775,9 +775,30 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
container_base_path = DEFAULT_SKILLS_CONTAINER_PATH
skill_evolution_enabled = False
else:
config = app_config
container_base_path = config.skills.container_path
skill_evolution_enabled = config.skill_evolution.enabled
container_base_path = app_config.skills.container_path
skill_evolution_enabled = app_config.skill_evolution.enabled
skill_evolution_section = _build_skill_evolution_section(skill_evolution_enabled)
# ── Deferred discovery path — storage not needed (caller supplies names) ─
if skill_names is not None:
from deerflow.skills.describe import get_skill_index_prompt_section
return get_skill_index_prompt_section(
skill_names=skill_names,
container_base_path=container_base_path,
skill_evolution_section=skill_evolution_section,
)
# ── Legacy full-metadata path — load ALL skills for disabled-skill section
if user_id:
storage = get_or_new_user_skill_storage(user_id, app_config=app_config)
else:
storage = get_or_new_skill_storage(app_config=app_config)
all_skills = storage.load_skills(enabled_only=False)
disabled_skills = [s for s in all_skills if not s.enabled]
skills = get_enabled_skills_for_config(app_config, user_id=user_id)
if not skills and not disabled_skills and not skill_evolution_enabled:
return ""
@ -790,7 +811,6 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
if not skill_signature and not disabled_skill_signature and available_key is not None:
return ""
skill_evolution_section = _build_skill_evolution_section(skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, disabled_skill_signature, available_key, container_base_path, skill_evolution_section)
@ -883,6 +903,7 @@ def apply_prompt_template(
app_config: AppConfig | None = None,
deferred_names: frozenset[str] = frozenset(),
user_id: str | None = None,
skill_names: frozenset[str] | None = None,
) -> str:
# Include subagent section only if enabled (from runtime parameter)
n = max_concurrent_subagents
@ -906,8 +927,13 @@ def apply_prompt_template(
else ""
)
# Get skills section
skills_section = get_skills_prompt_section(available_skills, app_config=app_config, user_id=user_id)
# Get skills section (deferred discovery when skill_names is provided)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
user_id=user_id,
skill_names=skill_names,
)
# Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(deferred_names=deferred_names)
@ -917,6 +943,14 @@ def apply_prompt_template(
custom_mounts_section = _build_custom_mounts_section(app_config=app_config)
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
# Gate the "Skill First" instruction on the deferred discovery path:
# legacy mode uses tool-agnostic wording; deferred mode references describe_skill.
skill_first_reminder = (
"- Skill First: For complex tasks, call describe_skill(name) to check if a matching skill exists, then read_file to load it.\n"
if skill_names is not None
else "- Skill First: Always load the relevant skill before starting **complex** tasks.\n"
)
# Build and return the fully static system prompt.
# Memory and current date are injected per-turn via DynamicContextMiddleware
# as a <system-reminder> in the first HumanMessage, keeping this prompt
@ -929,6 +963,7 @@ def apply_prompt_template(
deferred_tools_section=deferred_tools_section,
subagent_section=subagent_section,
subagent_reminder=subagent_reminder,
skill_first_reminder=skill_first_reminder,
subagent_thinking=subagent_thinking,
acp_section=acp_and_mounts_section,
)

View File

@ -35,7 +35,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool
from langchain_core.runnables import RunnableConfig
from deerflow.agents.lead_agent.agent import build_middlewares
from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.lead_agent.prompt import apply_prompt_template, get_enabled_skills_for_config
from deerflow.agents.thread_state import ThreadState
from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.app_config import get_app_config, is_trace_correlation_enabled, reload_app_config
@ -44,6 +44,7 @@ from deerflow.config.paths import get_paths
from deerflow.models import create_chat_model
from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_state, goal_thread_lock, read_thread_goal, write_thread_goal
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.describe import build_skill_search_setup
from deerflow.skills.storage import get_or_new_user_skill_storage
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, generate_trace_id, get_current_trace_id, reset_current_trace_id, set_current_trace_id
@ -256,6 +257,19 @@ class DeerFlowClient:
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled)
final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled)
# Wire deferred skill discovery — mirrors agent.py so config flag works on both paths.
skills_list = get_enabled_skills_for_config(self._app_config)
if self._available_skills is not None:
skills_list = [s for s in skills_list if s.name in self._available_skills]
skill_setup = build_skill_search_setup(
skills_list,
enabled=self._app_config.skills.deferred_discovery,
container_base_path=self._app_config.skills.container_path,
)
if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool)
kwargs: dict[str, Any] = {
# attach_tracing=False because ``stream()`` injects tracing
# callbacks at the graph invocation root so a single embedded run
@ -281,6 +295,7 @@ class DeerFlowClient:
app_config=self._app_config,
deferred_names=deferred_setup.deferred_names,
user_id=get_effective_user_id(),
skill_names=skill_setup.skill_names or None,
),
"state_schema": ThreadState,
}

View File

@ -29,6 +29,10 @@ class SkillsConfig(BaseModel):
default=DEFAULT_SKILLS_CONTAINER_PATH,
description="Path where skills are mounted in the sandbox container",
)
deferred_discovery: bool = Field(
default=False,
description=("When enabled, skill metadata is not injected into the system prompt. Instead, only skill names appear in <skill_index> and the LLM discovers details on demand via the describe_skill tool."),
)
def get_skills_path(self) -> Path:
"""

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from .catalog import SkillCatalog
from .describe import SkillSearchSetup, build_describe_skill_tool, build_skill_search_setup
from .installer import SkillAlreadyExistsError, SkillSecurityScanError
from .storage import LocalSkillStorage, SkillStorage, get_or_new_skill_storage
from .types import Skill
@ -7,6 +9,10 @@ from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmat
__all__ = [
"Skill",
"SkillCatalog",
"SkillSearchSetup",
"build_describe_skill_tool",
"build_skill_search_setup",
"ALLOWED_FRONTMATTER_PROPERTIES",
"_validate_skill_frontmatter",
"SkillAlreadyExistsError",

View File

@ -0,0 +1,102 @@
"""Skill catalog — deferred skill discovery at runtime.
Mirrors ``DeferredToolCatalog`` from ``tool_search.py``: an immutable, searchable
catalog that lets the LLM discover skill metadata on demand rather than having
every skill's full description baked into the system prompt.
The agent sees skill names in ``<skill_index>`` but cannot read their metadata
until it calls ``describe_skill``. This keeps the system prompt compact and
prefix-cache friendly while still giving the model autonomous skill discovery.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from functools import cached_property
from deerflow.skills.types import Skill
logger = logging.getLogger(__name__)
MAX_RESULTS = 5
def _compile_catalog_regex(pattern: str) -> re.Pattern[str]:
"""Compile ``pattern`` case-insensitively, falling back to literal match.
Search queries come from the model, so an invalid regex (e.g. an unbalanced
paren) must degrade to a literal substring match rather than raise.
"""
try:
return re.compile(pattern, re.IGNORECASE)
except re.error:
return re.compile(re.escape(pattern), re.IGNORECASE)
# NOTE: frozen=True without slots=True keeps __dict__, which is what lets the
# @cached_property fields below cache (they write to instance.__dict__, bypassing
# the frozen __setattr__). Do NOT add slots=True or hash/names break at runtime.
@dataclass(frozen=True)
class SkillCatalog:
"""Immutable catalog of skills. Pure search, no mutation.
Query forms (mirror ``DeferredToolCatalog.search``):
- ``"select:data-analysis,deep-research"`` exact match by name.
- ``"+podcast gen"`` require *podcast* in the name, rank by *gen*.
- ``"chart visualization"`` regex match on name + description.
"""
skills: tuple[Skill, ...]
@cached_property
def names(self) -> frozenset[str]:
"""All skill names in insertion order."""
return frozenset(s.name for s in self.skills)
def search(self, query: str) -> list[Skill]:
"""Match *query* against skill names and descriptions.
Returns at most ``MAX_RESULTS`` skills, ranked by relevance.
"""
query = query.strip()
if not query:
return []
# ── Exact selection ────────────────────────────────────────────
if query.startswith("select:"):
wanted = {n.strip() for n in query[7:].split(",")}
return [s for s in self.skills if s.name in wanted]
# ── Required-prefix search ─────────────────────────────────────
if query.startswith("+"):
parts = query[1:].split(None, 1)
if not parts:
return [] # bare "+" with no required token
required = parts[0].lower()
candidates = [s for s in self.skills if required in s.name.lower()]
if len(parts) > 1:
pattern = _compile_catalog_regex(parts[1])
candidates.sort(
key=lambda s: _catalog_regex_score(pattern, s),
reverse=True,
)
return candidates[:MAX_RESULTS]
# ── Free-text regex search ─────────────────────────────────────
regex = _compile_catalog_regex(query)
scored: list[tuple[int, Skill]] = []
for s in self.skills:
searchable = f"{s.name} {s.description or ''}"
if regex.search(searchable):
# Name match scores higher than description-only match.
scored.append((2 if regex.search(s.name) else 1, s))
scored.sort(key=lambda x: x[0], reverse=True)
return [s for _, s in scored][:MAX_RESULTS]
def _catalog_regex_score(pattern: re.Pattern[str], s: Skill) -> int:
"""Count regex hits across name + description for ranking."""
return len(pattern.findall(f"{s.name} {s.description or ''}"))

View File

@ -0,0 +1,180 @@
"""describe_skill — deferred skill metadata retrieval at runtime.
Builds the ``describe_skill`` tool as a closure over a :class:`SkillCatalog`.
The tool returns structured metadata (description, allowed tools, file location)
so the LLM can decide whether to ``read_file`` the full SKILL.md.
Mirrors ``build_tool_search_tool`` from ``tool_search.py``: same query syntax,
same ``Command`` + ``ToolMessage`` return shape, same fail-safe degradation.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from langchain_core.messages import ToolMessage
from langchain_core.tools import InjectedToolCallId, tool
from langgraph.types import Command
if TYPE_CHECKING:
from langchain.tools import BaseTool
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.skills.catalog import SkillCatalog
from deerflow.skills.types import SkillCategory
logger = logging.getLogger(__name__)
# ── Setup ────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SkillSearchSetup:
"""Result of assembling skill search for one agent build.
Mirrors ``DeferredToolSetup`` from ``tool_search.py``.
- **Empty** ``(None, frozenset())``: no skills available or skill search
disabled. The agent falls back to the legacy full-metadata prompt.
- **Populated**: ``describe_skill_tool`` is appended to the agent's tools,
``skill_names`` are rendered in ``<skill_index>`` instead of full metadata.
"""
describe_skill_tool: BaseTool | None
skill_names: frozenset[str]
def build_describe_skill_tool(
catalog: SkillCatalog,
*,
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
) -> BaseTool:
"""Build the ``describe_skill`` tool as a closure over *catalog*.
The returned tool is a plain ``@tool``-decorated function that searches the
catalog and returns a ``Command`` wrapping a ``ToolMessage``. No graph state
mutation is needed (unlike ``tool_search`` which promotes deferred tools).
"""
@tool
def describe_skill(
name: str,
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
"""Fetch usage metadata for installed skills so you can decide whether to load them.
Skills appear by name in <skill_index> in the system prompt. Until
fetched, only the name is known. This tool matches a query against
installed skills and returns their full metadata description, allowed
tools, and file location so you can decide whether to load the
SKILL.md via read_file.
Query forms:
- "select:data-analysis,deep-research" -- fetch these exact skills (no cap)
- "chart visualization" -- keyword search, best matches (up to 5)
- "+podcast gen" -- require "podcast" in the name, rank by remaining terms (up to 5)
"""
matched = catalog.search(name)
if not matched:
content = f"No skills matched: {name}"
else:
content = _render_skill_metadata(matched, container_base_path)
return Command(
update={
"messages": [
ToolMessage(
content=content,
tool_call_id=tool_call_id,
name="describe_skill",
)
],
}
)
return describe_skill
def build_skill_search_setup(
skills: list,
*,
enabled: bool,
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
) -> SkillSearchSetup:
"""Build the skill search setup from a filtered skill list.
Mirrors ``build_deferred_tool_setup`` from ``tool_search.py``.
Returns an empty setup when *enabled* is ``False`` or *skills* is empty.
"""
if not enabled or not skills:
return SkillSearchSetup(None, frozenset())
catalog = SkillCatalog(tuple(skills))
return SkillSearchSetup(
describe_skill_tool=build_describe_skill_tool(
catalog,
container_base_path=container_base_path,
),
skill_names=catalog.names,
)
# ── Rendering ────────────────────────────────────────────────────────────────
def _render_skill_metadata(skills: list, container_base_path: str) -> str:
"""Render structured metadata for a list of matched skills."""
blocks: list[str] = []
for s in skills:
mutability = "[custom, editable]" if s.category == SkillCategory.CUSTOM else "[built-in]"
tools_line = ", ".join(s.allowed_tools) if s.allowed_tools else "(all)"
location = s.get_container_file_path(container_base_path)
blocks.append(f"## Skill: {s.name}\n- Description: {s.description} {mutability}\n- Allowed tools: {tools_line}\n- Location: {location}")
return "\n\n".join(blocks)
# ── Prompt rendering ─────────────────────────────────────────────────────────
def get_skill_index_prompt_section(
*,
skill_names: frozenset[str] = frozenset(),
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
skill_evolution_section: str = "",
) -> str:
"""Generate ``<skill_system>`` with a name-only ``<skill_index>``.
Mirrors ``get_deferred_tools_prompt_section`` from ``tool_search.py``.
The agent knows what exists and can use ``describe_skill`` to load metadata.
Returns empty string when there are no skills.
"""
if not skill_names:
return ""
names = ", ".join(sorted(skill_names))
evolution = f"\n{skill_evolution_section}" if skill_evolution_section else ""
return f"""<skill_system>
You have access to skills that provide optimized workflows for specific tasks.
**Skill Discovery:**
1. Check <skill_index> for a skill name that matches your task
2. Call describe_skill(name) to fetch its description and capabilities
3. If the skill matches, call read_file on the returned location to load full instructions
4. Follow the skill's instructions precisely
**Explicit Slash Skill Activation:**
- If the user starts a request with `/<skill-name>`, that skill was explicitly requested.
- The runtime injects the activated skill content; do not call `read_file` for that SKILL.md again unless the injected skill references supporting resources you need.
{evolution}
<skill_index>
{names}
</skill_index>
Skills are located at: {container_base_path}
</skill_system>"""

View File

@ -43,11 +43,11 @@ def _format_yaml_error(skill_file: Path, exc: yaml.YAMLError, source: str) -> st
return "\n".join(lines)
def parse_allowed_tools(raw: object, skill_file: Path) -> list[str] | None:
def parse_allowed_tools(raw: object, skill_file: Path) -> tuple[str, ...] | None:
"""Parse the optional allowed-tools frontmatter field.
Returns None when the field is omitted. Returns a list when the field is a
YAML sequence of strings, including an empty list for explicit no-tool
Returns None when the field is omitted. Returns a tuple when the field is a
YAML sequence of strings, including an empty tuple for explicit no-tool
skills. Raises ValueError for malformed values.
"""
if raw is None:
@ -63,21 +63,21 @@ def parse_allowed_tools(raw: object, skill_file: Path) -> list[str] | None:
if not tool_name:
raise ValueError(f"allowed-tools in {skill_file} cannot contain empty tool names")
allowed_tools.append(tool_name)
return allowed_tools
return tuple(allowed_tools)
def parse_required_secrets(raw: object, skill_file: Path) -> list[SecretRequirement]:
def parse_required_secrets(raw: object, skill_file: Path) -> tuple[SecretRequirement, ...]:
"""Parse the optional required-secrets frontmatter field (issue #3861).
Accepts a YAML sequence whose items are either a string (the secret / env
variable name) or a mapping (``{name, optional}``). Returns an empty list
variable name) or a mapping (``{name, optional}``). Returns an empty tuple
when the field is omitted. Entries whose name is missing or is not a valid
environment-variable name are dropped with a warning, so one malformed
declaration does not invalidate the whole skill. Raises ValueError only when
the field is present but is not a list.
"""
if raw is None:
return []
return ()
if not isinstance(raw, list):
raise ValueError(f"required-secrets in {skill_file} must be a list")
@ -100,7 +100,7 @@ def parse_required_secrets(raw: object, skill_file: Path) -> list[SecretRequirem
continue
seen.add(name)
secrets.append(SecretRequirement(name=name, optional=optional))
return secrets
return tuple(secrets)
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import dataclasses
import logging
import re
from abc import ABC, abstractmethod
@ -267,8 +268,7 @@ class SkillStorage(ABC):
from deerflow.config.extensions_config import ExtensionsConfig
extensions_config = ExtensionsConfig.from_file()
for skill in skills:
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
skills = [dataclasses.replace(s, enabled=extensions_config.is_skill_enabled(s.name, s.category)) for s in skills]
except Exception as e:
logger.warning("Failed to load extensions config: %s", e)

View File

@ -29,6 +29,7 @@ two users own same-named custom skills.
from __future__ import annotations
import asyncio
import dataclasses
import json
import logging
import os
@ -205,12 +206,12 @@ class UserScopedSkillStorage(LocalSkillStorage):
from deerflow.config.extensions_config import get_extensions_config
extensions_config = get_extensions_config()
for skill in skills:
category = skill.category.value if hasattr(skill.category, "value") else skill.category
if category != SkillCategory.PUBLIC.value:
per_user_state = self.get_skill_enabled_state(skill.name)
global_state = extensions_config.is_skill_enabled(skill.name, category)
skill.enabled = per_user_state and global_state
skills = [
dataclasses.replace(s, enabled=self.get_skill_enabled_state(s.name) and extensions_config.is_skill_enabled(s.name, s.category.value if hasattr(s.category, "value") else s.category))
if dataclasses.is_dataclass(s) and not isinstance(s, type) and (s.category.value if hasattr(s.category, "value") else s.category) != SkillCategory.PUBLIC.value
else s
for s in skills
]
if enabled_only:
skills = [s for s in skills if s.enabled]

View File

@ -35,7 +35,7 @@ class SecretRequirement:
optional: bool = False
@dataclass
@dataclass(frozen=True)
class Skill:
"""Represents a skill with its metadata and file path"""
@ -46,9 +46,9 @@ class Skill:
skill_file: Path
relative_path: Path # Relative path from category root to skill directory
category: SkillCategory # 'public' or 'custom'
allowed_tools: list[str] | None = None
allowed_tools: tuple[str, ...] | None = None
enabled: bool = False # Whether this skill is enabled
required_secrets: list[SecretRequirement] = field(default_factory=list)
required_secrets: tuple[SecretRequirement, ...] = field(default_factory=tuple)
@property
def skill_path(self) -> str:

View File

@ -896,12 +896,17 @@ class TestClientCheckpointerFallback:
config_mock.get_model_config.return_value = MagicMock(supports_vision=False)
config_mock.checkpointer = None
config_mock.skills.deferred_discovery = False
config_mock.skills.container_path = "/mnt/skills"
config_mock.tool_search.enabled = False
with (
patch("deerflow.client.get_app_config", return_value=config_mock),
patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.create_chat_model", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value=""),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch("deerflow.client.DeerFlowClient._get_tools", return_value=[]),
):
client = DeerFlowClient(checkpointer=None)
@ -930,12 +935,17 @@ class TestClientCheckpointerFallback:
config_mock.get_model_config.return_value = MagicMock(supports_vision=False)
config_mock.checkpointer = None
config_mock.skills.deferred_discovery = False
config_mock.skills.container_path = "/mnt/skills"
config_mock.tool_search.enabled = False
with (
patch("deerflow.client.get_app_config", return_value=config_mock),
patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.create_chat_model", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value=""),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch("deerflow.client.DeerFlowClient._get_tools", return_value=[]),
):
client = DeerFlowClient(checkpointer=explicit_cp)

View File

@ -41,6 +41,9 @@ def mock_app_config():
config = MagicMock()
config.models = [model]
config.token_usage.enabled = False
config.skills.deferred_discovery = False
config.skills.container_path = "/mnt/skills"
config.tool_search.enabled = False
return config
@ -916,6 +919,7 @@ class TestEnsureAgent:
patch("deerflow.client.create_agent", return_value=mock_agent),
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt,
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()),
):
@ -941,6 +945,7 @@ class TestEnsureAgent:
patch("deerflow.client.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=mock_checkpointer),
):
@ -966,6 +971,7 @@ class TestEnsureAgent:
patch("deerflow.client.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", side_effect=fake_build_middlewares),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()),
):
@ -985,6 +991,7 @@ class TestEnsureAgent:
patch("deerflow.client.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
@ -1004,6 +1011,82 @@ class TestEnsureAgent:
# Should still be the same mock — no recreation
assert client._agent is mock_agent
def test_deferred_skill_discovery_wired_when_enabled(self, client, mock_app_config):
"""When skills.deferred_discovery=True, skill_names reaches apply_prompt_template
(parity with agent.py config flag must not be a silent no-op on the embedded path)."""
from pathlib import Path
from deerflow.skills.types import Skill, SkillCategory
fake_skill = Skill(
name="deep-research",
description="Multi-source research",
license=None,
skill_dir=Path("/mnt/skills/public/deep-research"),
skill_file=Path("/mnt/skills/public/deep-research/SKILL.md"),
relative_path=Path("deep-research"),
category=SkillCategory.PUBLIC,
enabled=True,
)
mock_app_config.skills.deferred_discovery = True
mock_app_config.skills.container_path = "/mnt/skills"
mock_app_config.tool_search.enabled = False
client._app_config = mock_app_config
config = client._get_runnable_config("t1")
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt,
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[fake_skill]),
):
client._ensure_agent(config)
skill_names_arg = mock_apply_prompt.call_args.kwargs.get("skill_names")
assert skill_names_arg is not None, "skill_names must be passed when deferred_discovery=True"
assert "deep-research" in skill_names_arg
def test_deferred_skill_discovery_not_wired_when_disabled(self, client, mock_app_config):
"""When skills.deferred_discovery=False, skill_names is None so the legacy prompt path runs."""
from pathlib import Path
from deerflow.skills.types import Skill, SkillCategory
fake_skill = Skill(
name="deep-research",
description="Multi-source research",
license=None,
skill_dir=Path("/mnt/skills/public/deep-research"),
skill_file=Path("/mnt/skills/public/deep-research/SKILL.md"),
relative_path=Path("deep-research"),
category=SkillCategory.PUBLIC,
enabled=True,
)
mock_app_config.skills.deferred_discovery = False
mock_app_config.skills.container_path = "/mnt/skills"
mock_app_config.tool_search.enabled = False
client._app_config = mock_app_config
config = client._get_runnable_config("t1")
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt,
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[fake_skill]),
):
client._ensure_agent(config)
skill_names_arg = mock_apply_prompt.call_args.kwargs.get("skill_names")
assert skill_names_arg is None, "skill_names must be None when deferred_discovery=False"
# ---------------------------------------------------------------------------
# get_model
@ -1997,6 +2080,7 @@ class TestScenarioAgentRecreation:
patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()),
):
@ -2025,6 +2109,7 @@ class TestScenarioAgentRecreation:
patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()),
):
@ -2050,6 +2135,7 @@ class TestScenarioAgentRecreation:
patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()),
):

View File

@ -137,7 +137,7 @@ def _make_skill(allowed_tools):
skill_file=Path("/tmp/s/SKILL.md"),
relative_path=Path("s"),
category="public",
allowed_tools=allowed_tools,
allowed_tools=tuple(allowed_tools) if allowed_tools is not None else None,
enabled=True,
)

View File

@ -292,8 +292,8 @@ def test_explicit_config_enabled_skills_are_cached_by_config_identity(monkeypatc
def load_skills(*, enabled_only):
nonlocal load_count
load_count += 1
assert enabled_only is True
if enabled_only:
load_count += 1
return [make_skill("cached-skill")]
return SimpleNamespace(load_skills=load_skills)
@ -417,5 +417,52 @@ def test_system_prompt_template_preserves_placeholders():
"{subagent_section}",
"{acp_section}",
"{subagent_reminder}",
"{skill_first_reminder}",
):
assert ph in template, f"placeholder {ph} accidentally removed"
def _make_minimal_app_config():
return SimpleNamespace(
sandbox=SimpleNamespace(mounts=[]),
skills=SimpleNamespace(container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=False),
tool_search=SimpleNamespace(enabled=False),
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
acp_agents={},
)
def test_apply_prompt_template_legacy_path_does_not_mention_describe_skill(monkeypatch):
"""When skill_names is None (legacy path), critical_reminders must not
reference describe_skill (the tool is not registered in legacy mode)."""
config = _make_minimal_app_config()
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: []))
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
prompt = prompt_module.apply_prompt_template(app_config=config)
# Legacy wording — tool-agnostic
assert "Always load the relevant skill" in prompt
# Must NOT reference the deferred tool
assert "describe_skill(name)" not in prompt
def test_apply_prompt_template_deferred_path_mentions_describe_skill(monkeypatch):
"""When skill_names is provided (deferred path), critical_reminders must
reference describe_skill so the LLM knows how to discover skills."""
config = _make_minimal_app_config()
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: []))
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
prompt = prompt_module.apply_prompt_template(
app_config=config,
skill_names=frozenset({"data-analysis"}),
)
# Deferred wording — references describe_skill
assert "describe_skill(name)" in prompt
# Must NOT contain the legacy wording
assert "Always load the relevant skill" not in prompt

View File

@ -20,7 +20,7 @@ def _make_skill(name: str, allowed_tools: list[str] | None = None) -> Skill:
skill_file=Path(f"/tmp/{name}/SKILL.md"),
relative_path=Path(name),
category="public",
allowed_tools=allowed_tools,
allowed_tools=tuple(allowed_tools) if allowed_tools is not None else None,
enabled=True,
)
@ -170,6 +170,61 @@ def test_get_skills_prompt_section_uses_explicit_config_for_enabled_skills(monke
assert "global-skill" not in result
def test_get_skills_prompt_section_deferred_path_uses_skill_index(monkeypatch):
"""When skill_names is provided, renders <skill_index> instead of <available_skills>."""
skills = [_make_skill("data-analysis"), _make_skill("deep-research")]
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
monkeypatch.setattr(
"deerflow.config.get_app_config",
lambda: SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=False),
),
)
# Deferred path never touches storage, but patch defensively in case of fallback.
_null_storage = SimpleNamespace(load_skills=lambda *, enabled_only: [])
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kw: _null_storage)
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda *a, **kw: _null_storage)
# Deferred path: skill_names provided
result = get_skills_prompt_section(
available_skills=None,
skill_names=frozenset({"data-analysis", "deep-research"}),
)
assert "<skill_index>" in result
assert "data-analysis" in result
assert "deep-research" in result
assert "describe_skill" in result
# Must NOT contain legacy full-metadata format
assert "<available_skills>" not in result
assert "Description for data-analysis" not in result # descriptions excluded from index
def test_get_skills_prompt_section_legacy_path_when_skill_names_none(monkeypatch):
"""When skill_names is None, falls back to legacy <available_skills> rendering."""
skills = [_make_skill("data-analysis")]
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
monkeypatch.setattr(
"deerflow.config.get_app_config",
lambda: SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=False),
),
)
# Legacy path loads ALL skills (enabled + disabled) from storage for the disabled-skills section.
_storage = SimpleNamespace(load_skills=lambda *, enabled_only: skills)
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kw: _storage)
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda *a, **kw: _storage)
# Legacy path: skill_names not provided
result = get_skills_prompt_section(available_skills=None)
assert "<available_skills>" in result
assert "data-analysis" in result
assert "Description for data-analysis" in result
assert "<skill_index>" not in result
assert "describe_skill" not in result
def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch):
from unittest.mock import MagicMock
@ -231,11 +286,17 @@ def test_make_lead_agent_filters_tools_from_available_skills(monkeypatch):
mock_app_config = MagicMock()
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False)
mock_app_config.tool_search.enabled = True
mock_app_config.skills.container_path = "/mnt/skills"
mock_app_config.skills.deferred_discovery = True # describe_skill will be added
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
assert [tool.name for tool in agent_kwargs["tools"]] == ["read_file", "web_search"]
# With skills.deferred_discovery=True, describe_skill is added to tools
tool_names = [tool.name for tool in agent_kwargs["tools"]]
assert "read_file" in tool_names
assert "describe_skill" in tool_names
def test_skill_allowed_tools_default_does_not_preserve_read_file_for_subagents():
@ -269,7 +330,9 @@ def test_make_lead_agent_all_legacy_skills_preserve_all_tools(monkeypatch):
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
assert [tool.name for tool in agent_kwargs["tools"]] == ["bash", "read_file", "update_agent"]
# describe_skill is appended after skill-allowed-tools filtering (it bypasses policy).
tool_names = [tool.name for tool in agent_kwargs["tools"]]
assert tool_names == ["bash", "read_file", "update_agent", "describe_skill"]
def test_make_lead_agent_enforces_allowed_tools_when_skill_cache_is_cold(monkeypatch):
@ -298,7 +361,9 @@ def test_make_lead_agent_enforces_allowed_tools_when_skill_cache_is_cold(monkeyp
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
assert [tool.name for tool in agent_kwargs["tools"]] == ["read_file"]
# describe_skill is appended after skill-allowed-tools filtering (it bypasses policy).
tool_names = [tool.name for tool in agent_kwargs["tools"]]
assert tool_names == ["read_file", "describe_skill"]
def test_make_lead_agent_fails_closed_when_skill_policy_load_fails(monkeypatch):

View File

@ -0,0 +1,193 @@
"""Tests for SkillCatalog — deferred skill discovery search engine."""
from pathlib import Path
import pytest
from deerflow.skills.catalog import MAX_RESULTS, SkillCatalog
from deerflow.skills.types import Skill, SkillCategory
# ── Fixtures ──────────────────────────────────────────────────────────────────
def _make_skill(
name: str,
description: str = "A skill",
category: SkillCategory = SkillCategory.PUBLIC,
allowed_tools: tuple[str, ...] | None = None,
) -> Skill:
"""Create a minimal Skill for testing."""
base = Path("/mnt/skills") / category.value / name
return Skill(
name=name,
description=description,
license=None,
skill_dir=base,
skill_file=base / "SKILL.md",
relative_path=Path(name),
category=category,
allowed_tools=allowed_tools,
enabled=True,
)
@pytest.fixture
def sample_skills() -> list[Skill]:
return [
_make_skill("data-analysis", "Analyze data with Python, pandas, jupyter"),
_make_skill("deep-research", "Conduct multi-source research with fact-checking"),
_make_skill("chart-visualization", "Visualize data with interactive charts"),
_make_skill("podcast-generation", "Generate podcast scripts and audio"),
_make_skill("music-generation", "Generate music compositions"),
_make_skill("video-generation", "Generate video from text prompts"),
_make_skill("image-generation", "Generate images from descriptions"),
_make_skill("ppt-generation", "Generate PowerPoint presentations"),
_make_skill("custom-analyzer", "Custom data analyzer", category=SkillCategory.CUSTOM),
]
@pytest.fixture
def catalog(sample_skills: list[Skill]) -> SkillCatalog:
return SkillCatalog(tuple(sample_skills))
# ── Name property ─────────────────────────────────────────────────────────────
def test_names_returns_frozenset(catalog: SkillCatalog):
assert isinstance(catalog.names, frozenset)
def test_names_contains_all_skills(catalog: SkillCatalog, sample_skills: list[Skill]):
expected = {s.name for s in sample_skills}
assert catalog.names == expected
def test_empty_catalog_names():
catalog = SkillCatalog(())
assert catalog.names == frozenset()
# ── Exact selection (select:) ─────────────────────────────────────────────────
def test_select_single(catalog: SkillCatalog):
result = catalog.search("select:data-analysis")
assert len(result) == 1
assert result[0].name == "data-analysis"
def test_select_multiple(catalog: SkillCatalog):
result = catalog.search("select:data-analysis,deep-research")
names = {s.name for s in result}
assert names == {"data-analysis", "deep-research"}
def test_select_nonexistent(catalog: SkillCatalog):
result = catalog.search("select:nonexistent-skill")
assert result == []
def test_select_partial_match(catalog: SkillCatalog):
"""select: with one valid and one invalid name returns only the valid one."""
result = catalog.search("select:data-analysis,nonexistent")
assert len(result) == 1
assert result[0].name == "data-analysis"
def test_select_returns_all_requested(catalog: SkillCatalog, sample_skills: list[Skill]):
"""select: returns all requested names without capping — exact selection, not ranked search."""
all_names = ",".join(sorted(catalog.names))
result = catalog.search(f"select:{all_names}")
assert len(result) == len(sample_skills)
# ── Required-prefix search (+) ────────────────────────────────────────────────
def test_required_prefix_filters_by_name(catalog: SkillCatalog):
result = catalog.search("+podcast")
assert all("podcast" in s.name for s in result)
def test_required_prefix_with_ranking(catalog: SkillCatalog):
"""'+gen generation' should require 'gen' in name, rank by 'generation'."""
result = catalog.search("+gen generation")
assert all("gen" in s.name for s in result)
def test_required_prefix_bare_plus(catalog: SkillCatalog):
"""Bare '+' with no token returns empty."""
result = catalog.search("+")
assert result == []
def test_required_prefix_no_match(catalog: SkillCatalog):
result = catalog.search("+zzz_nonexistent")
assert result == []
# ── Free-text regex search ────────────────────────────────────────────────────
def test_keyword_matches_name(catalog: SkillCatalog):
result = catalog.search("podcast")
assert any(s.name == "podcast-generation" for s in result)
def test_keyword_matches_description(catalog: SkillCatalog):
"""Description match should also be returned."""
result = catalog.search("pandas")
assert any(s.name == "data-analysis" for s in result)
def test_name_match_scores_higher_than_description(catalog: SkillCatalog):
"""When both name and description match, name match should rank first."""
# 'data-analysis' name matches 'data', description also matches 'data'
# 'deep-research' description matches 'data' (no, it doesn't)
# Let's use 'chart' — matches chart-visualization by name
result = catalog.search("chart")
assert result[0].name == "chart-visualization"
def test_regex_case_insensitive(catalog: SkillCatalog):
result_lower = catalog.search("data")
result_upper = catalog.search("DATA")
assert {s.name for s in result_lower} == {s.name for s in result_upper}
def test_invalid_regex_falls_back_to_literal(catalog: SkillCatalog):
"""Unbalanced paren should degrade to literal match, not raise."""
result = catalog.search("(invalid")
# Should not raise; may or may not match anything
assert isinstance(result, list)
def test_empty_query(catalog: SkillCatalog):
result = catalog.search("")
assert result == []
def test_whitespace_only_query(catalog: SkillCatalog):
result = catalog.search(" ")
assert result == []
def test_max_results_cap(catalog: SkillCatalog):
"""Free-text search should cap results at MAX_RESULTS."""
# 'generation' matches many descriptions
result = catalog.search("generation")
assert len(result) <= MAX_RESULTS
# ── Edge cases ────────────────────────────────────────────────────────────────
def test_frozen_catalog_is_hashable(catalog: SkillCatalog):
"""SkillCatalog with real skills must be hashable (frozen=True on both Skill and SkillCatalog)."""
assert hash(catalog) is not None
def test_names_cached_property_stable(catalog: SkillCatalog):
"""Multiple accesses to .names should return the same frozenset."""
assert catalog.names is catalog.names

View File

@ -0,0 +1,282 @@
"""Tests for describe_skill tool and skill index prompt rendering."""
from pathlib import Path
import pytest
from deerflow.skills.catalog import SkillCatalog
from deerflow.skills.describe import (
_render_skill_metadata,
build_describe_skill_tool,
build_skill_search_setup,
get_skill_index_prompt_section,
)
from deerflow.skills.types import Skill, SkillCategory
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_skill(
name: str,
description: str = "A skill",
category: SkillCategory = SkillCategory.PUBLIC,
allowed_tools: tuple[str, ...] | None = None,
) -> Skill:
base = Path("/mnt/skills") / category.value / name
return Skill(
name=name,
description=description,
license=None,
skill_dir=base,
skill_file=base / "SKILL.md",
relative_path=Path(name),
category=category,
allowed_tools=allowed_tools,
enabled=True,
)
@pytest.fixture
def sample_skills() -> list[Skill]:
return [
_make_skill("data-analysis", "Analyze data with Python", allowed_tools=("execute_code", "read_file")),
_make_skill("deep-research", "Multi-source research"),
_make_skill("custom-analyzer", "Custom analyzer", category=SkillCategory.CUSTOM),
]
@pytest.fixture
def catalog(sample_skills: list[Skill]) -> SkillCatalog:
return SkillCatalog(tuple(sample_skills))
# ── _render_skill_metadata ────────────────────────────────────────────────────
def test_render_metadata_format(sample_skills: list[Skill]):
rendered = _render_skill_metadata(sample_skills[:1], "/mnt/skills")
assert "## Skill: data-analysis" in rendered
assert "Description: Analyze data with Python" in rendered
assert "[built-in]" in rendered
assert "Allowed tools: execute_code, read_file" in rendered
assert "Location: /mnt/skills/public/data-analysis/SKILL.md" in rendered
def test_render_custom_skill_mutability(sample_skills: list[Skill]):
custom = [s for s in sample_skills if s.category == SkillCategory.CUSTOM]
rendered = _render_skill_metadata(custom, "/mnt/skills")
assert "[custom, editable]" in rendered
def test_render_no_allowed_tools_shows_all(sample_skills: list[Skill]):
"""Skills without allowed_tools should show '(all)'."""
no_tools = [s for s in sample_skills if s.allowed_tools is None]
rendered = _render_skill_metadata(no_tools[:1], "/mnt/skills")
assert "Allowed tools: (all)" in rendered
def test_render_multiple_skills(sample_skills: list[Skill]):
rendered = _render_skill_metadata(sample_skills, "/mnt/skills")
assert "## Skill: data-analysis" in rendered
assert "## Skill: deep-research" in rendered
assert "## Skill: custom-analyzer" in rendered
# ── build_describe_skill_tool ─────────────────────────────────────────────────
def test_describe_tool_is_invokable(catalog: SkillCatalog):
tool = build_describe_skill_tool(catalog)
assert tool.name == "describe_skill"
assert hasattr(tool, "invoke")
def test_describe_tool_docstring(catalog: SkillCatalog):
tool = build_describe_skill_tool(catalog)
assert "describe_skill" in tool.name
assert tool.description is not None
def test_describe_skill_parameter_name_matches_prompt(catalog: SkillCatalog):
"""Regression: the tool parameter must be 'name', matching the prompt wording
'describe_skill(name)'. A strict function-calling model submits exactly the
parameter name the prompt specifies any drift silently breaks the flow.
"""
tool = build_describe_skill_tool(catalog)
schema = tool.get_input_schema().model_json_schema()
assert "name" in schema["properties"], "tool must accept 'name' (matching prompt wording)"
assert "query" not in schema["properties"], "old 'query' parameter must not exist"
# ── build_skill_search_setup ──────────────────────────────────────────────────
def test_setup_enabled_with_skills(sample_skills: list[Skill]):
setup = build_skill_search_setup(sample_skills, enabled=True)
assert setup.describe_skill_tool is not None
assert setup.skill_names == frozenset(s.name for s in sample_skills)
def test_setup_disabled():
setup = build_skill_search_setup([_make_skill("a", "A")], enabled=False)
assert setup.describe_skill_tool is None
assert setup.skill_names == frozenset()
def test_setup_empty_skills():
setup = build_skill_search_setup([], enabled=True)
assert setup.describe_skill_tool is None
assert setup.skill_names == frozenset()
def test_setup_frozen():
"""Empty SkillSearchSetup (describe_skill_tool=None) must be hashable.
The populated setup contains a BaseTool, which is not hashable by design
so only the disabled/empty path is required to hash. frozen=True still
prevents accidental mutation in both cases.
"""
setup = build_skill_search_setup([], enabled=True)
assert hash(setup) is not None
# ── get_skill_index_prompt_section ────────────────────────────────────────────
def test_skill_index_contains_names():
section = get_skill_index_prompt_section(
skill_names=frozenset({"data-analysis", "deep-research"}),
)
assert "<skill_index>" in section
assert "data-analysis" in section
assert "deep-research" in section
def test_skill_index_no_description():
"""Index should NOT contain descriptions (that's the whole point)."""
section = get_skill_index_prompt_section(
skill_names=frozenset({"data-analysis"}),
)
assert "Analyze data with Python" not in section
def test_skill_index_no_location():
"""Index should NOT contain file paths."""
section = get_skill_index_prompt_section(
skill_names=frozenset({"data-analysis"}),
)
assert "/mnt/skills/public/data-analysis/SKILL.md" not in section
def test_skill_index_contains_discovery_instructions():
section = get_skill_index_prompt_section(
skill_names=frozenset({"data-analysis"}),
)
assert "describe_skill" in section
assert "Skill Discovery" in section
def test_skill_index_empty_returns_empty():
section = get_skill_index_prompt_section(skill_names=frozenset())
assert section == ""
def test_skill_index_default_returns_empty():
section = get_skill_index_prompt_section()
assert section == ""
def test_skill_index_with_evolution_section():
section = get_skill_index_prompt_section(
skill_names=frozenset({"a"}),
skill_evolution_section="## Skill Self-Evolution\n...",
)
assert "Skill Self-Evolution" in section
def test_skill_index_without_evolution_section():
section = get_skill_index_prompt_section(
skill_names=frozenset({"a"}),
skill_evolution_section="",
)
assert "Skill Self-Evolution" not in section
def test_skill_index_custom_container_path():
section = get_skill_index_prompt_section(
skill_names=frozenset({"a"}),
container_base_path="/custom/skills",
)
assert "/custom/skills" in section
def test_skill_index_names_are_sorted():
"""Names should be sorted for deterministic output."""
section = get_skill_index_prompt_section(
skill_names=frozenset({"z-skill", "a-skill", "m-skill"}),
)
# Extract just the <skill_index> block content
import re
match = re.search(r"<skill_index>\n(.*?)\n</skill_index>", section, re.DOTALL)
assert match is not None
names_str = match.group(1).strip()
names = [n.strip() for n in names_str.split(",")]
assert names == sorted(names)
# ── Integration: describe_skill tool invocation ───────────────────────────────
def test_describe_tool_returns_command_with_tool_message(catalog: SkillCatalog):
"""describe_skill should return a Command with a ToolMessage."""
tool = build_describe_skill_tool(catalog)
# Tools with InjectedToolCallId must be invoked with a full ToolCall dict
result = tool.invoke(
{"args": {"name": "select:data-analysis"}, "name": "describe_skill", "type": "tool_call", "id": "test_call_123"},
)
# Result is a Command wrapping a ToolMessage
messages = result.update["messages"]
assert len(messages) == 1
msg = messages[0]
assert msg.name == "describe_skill"
assert msg.tool_call_id == "test_call_123"
assert "## Skill: data-analysis" in msg.content
def test_describe_tool_no_match(catalog: SkillCatalog):
tool = build_describe_skill_tool(catalog)
result = tool.invoke(
{"args": {"name": "xyz_nonexistent"}, "name": "describe_skill", "type": "tool_call", "id": "test_call_456"},
)
messages = result.update["messages"]
assert "No skills matched" in messages[0].content
def test_describe_tool_keyword_search(catalog: SkillCatalog):
tool = build_describe_skill_tool(catalog)
result = tool.invoke(
{"args": {"name": "research"}, "name": "describe_skill", "type": "tool_call", "id": "test_call_789"},
)
messages = result.update["messages"]
assert "deep-research" in messages[0].content
def test_describe_tool_select_uncapped(tmp_path):
"""select: must return ALL requested skills, not capped at MAX_RESULTS."""
from deerflow.skills.catalog import MAX_RESULTS
# Build more skills than MAX_RESULTS so the cap would visibly truncate
many_skills = [_make_skill(f"skill-{i:02d}") for i in range(MAX_RESULTS + 2)]
big_catalog = SkillCatalog(tuple(many_skills))
tool = build_describe_skill_tool(big_catalog)
names_csv = ",".join(s.name for s in many_skills)
result = tool.invoke(
{"args": {"name": f"select:{names_csv}"}, "name": "describe_skill", "type": "tool_call", "id": "test_select_uncapped"},
)
content = result.update["messages"][0].content
for s in many_skills:
assert s.name in content, f"select: truncated — {s.name} missing from result"

View File

@ -228,7 +228,7 @@ class TestRequiredSecretsParsing:
skill_file = self._write_skill(tmp_path, "name: erp-report\ndescription: Pull an ERP report")
skill = parse_skill_file(skill_file, SkillCategory.CUSTOM)
assert skill is not None
assert skill.required_secrets == []
assert skill.required_secrets == ()
def test_string_list_form(self, tmp_path):
from deerflow.skills.parser import parse_skill_file

View File

@ -98,14 +98,14 @@ def test_parse_allowed_tools_list(tmp_path):
skill_file = _write_skill(tmp_path, 'name: my-skill\ndescription: Test\nallowed-tools: ["bash", "read_file"]')
skill = parse_skill_file(skill_file, category="custom")
assert skill is not None
assert skill.allowed_tools == ["bash", "read_file"]
assert skill.allowed_tools == ("bash", "read_file")
def test_parse_empty_allowed_tools_list(tmp_path):
skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test\nallowed-tools: []")
skill = parse_skill_file(skill_file, category="custom")
assert skill is not None
assert skill.allowed_tools == []
assert skill.allowed_tools == ()
def test_parse_invalid_allowed_tools_returns_none(tmp_path):

View File

@ -106,8 +106,9 @@ def test_resolve_slash_skill_respects_available_skill_whitelist(tmp_path):
def test_resolve_slash_skill_rejects_disabled_skills(tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
skill.enabled = False
import dataclasses
skill = dataclasses.replace(_make_skill(tmp_path, "data-analysis"), enabled=False)
assert resolve_slash_skill("/data-analysis run", [skill]) is None
@ -466,8 +467,9 @@ def test_skill_activation_middleware_returns_clear_error_for_missing_skill(monke
def test_skill_activation_middleware_returns_clear_error_for_disabled_skill(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
skill.enabled = False
import dataclasses
skill = dataclasses.replace(_make_skill(tmp_path, "data-analysis"), enabled=False)
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()

View File

@ -165,7 +165,7 @@ def _skill(name: str, allowed_tools: list[str] | None) -> Skill:
skill_file=skill_dir / "SKILL.md",
relative_path=Path(name),
category="custom",
allowed_tools=allowed_tools,
allowed_tools=tuple(allowed_tools) if allowed_tools is not None else None,
enabled=True,
)

View File

@ -1180,6 +1180,13 @@ skills:
# Default: /mnt/skills
container_path: /mnt/skills
# Deferred skill discovery (default: false)
# When enabled, only skill names appear in the system prompt (<skill_index>).
# The LLM discovers skill details on demand via the describe_skill tool.
# This keeps the system prompt compact and prefix-cache friendly when many
# skills are installed.
# deferred_discovery: true
# Note: To restrict which skills are loaded for a specific custom agent,
# define a `skills` list in that agent's `config.yaml` (e.g. `agents/my-agent/config.yaml`):
# - Omitted or null: load all globally enabled skills (default)