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}/` - **Location**: `deer-flow/skills/{public,custom}/`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets) - **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 - **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. - **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 - **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 - `tool_groups[]` - Logical groupings for tools
- `sandbox.use` - Sandbox provider class path - `sandbox.use` - Sandbox provider class path
- `skills.path` / `skills.container_path` - Host and container paths to skills directory - `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) - `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) - `summarization` - Context summarization (enabled, trigger conditions, keep policy)
- `subagents.enabled` - Master switch for subagent delegation - `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) 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: if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow # Special bootstrap agent with minimal prompt for initial custom agent creation flow
# Keep the bootstrap skill set intentionally narrow so agent creation # Keep the bootstrap skill set intentionally narrow so agent creation
# remains deterministic before the custom agent's own config exists. # 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] 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) filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES)
if non_interactive: if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES] 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) 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( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools, tools=final_tools,
@ -547,12 +562,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
app_config=resolved_app_config, app_config=resolved_app_config,
deferred_names=setup.deferred_names, deferred_names=setup.deferred_names,
user_id=resolved_user_id, user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
), ),
state_schema=ThreadState, state_schema=ThreadState,
) )
# Custom agents can update their own SOUL.md / config via update_agent. # Custom agents can update their own SOUL.md / config via update_agent.
# The default agent (no agent_name) does not see this tool. # 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 # Withhold ``update_agent`` from runs triggered by webhook channels
# (currently only ``github``). Webhook prompts come from arbitrary # (currently only ``github``). Webhook prompts come from arbitrary
@ -575,6 +598,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if non_interactive: if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES] 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) 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( 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), 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, tools=final_tools,
@ -595,6 +620,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
app_config=resolved_app_config, app_config=resolved_app_config,
deferred_names=setup.deferred_names, deferred_names=setup.deferred_names,
user_id=resolved_user_id, user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
), ),
state_schema=ThreadState, state_schema=ThreadState,
) )

View File

@ -631,8 +631,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
<critical_reminders> <critical_reminders>
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess - **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. {subagent_reminder}{skill_first_reminder}
- Progressive Loading: Load resources incrementally as referenced in skills - 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) - 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 - File Editing Workflow: When revising an existing file, prefer
`str_replace` over `write_file` it sends only the diff and avoids `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>""" </skill_system>"""
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None, user_id: str | None = None) -> str: def get_skills_prompt_section(
"""Generate the skills prompt section with available skills list.""" available_skills: set[str] | None = None,
# 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 app_config: AppConfig | None = None,
user_id: str | None = None,
if user_id: skill_names: frozenset[str] | None = None,
storage = get_or_new_user_skill_storage(user_id, app_config=app_config) ) -> str:
else: """Generate the skills prompt section.
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)
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: if app_config is None:
try: try:
from deerflow.config import get_app_config 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 container_base_path = DEFAULT_SKILLS_CONTAINER_PATH
skill_evolution_enabled = False skill_evolution_enabled = False
else: else:
config = app_config container_base_path = app_config.skills.container_path
container_base_path = config.skills.container_path skill_evolution_enabled = app_config.skill_evolution.enabled
skill_evolution_enabled = 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: if not skills and not disabled_skills and not skill_evolution_enabled:
return "" 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 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: if not skill_signature and not disabled_skill_signature and available_key is not None:
return "" 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) 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, app_config: AppConfig | None = None,
deferred_names: frozenset[str] = frozenset(), deferred_names: frozenset[str] = frozenset(),
user_id: str | None = None, user_id: str | None = None,
skill_names: frozenset[str] | None = None,
) -> str: ) -> str:
# Include subagent section only if enabled (from runtime parameter) # Include subagent section only if enabled (from runtime parameter)
n = max_concurrent_subagents n = max_concurrent_subagents
@ -906,8 +927,13 @@ def apply_prompt_template(
else "" else ""
) )
# Get skills section # 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) 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) # Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(deferred_names=deferred_names) 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) 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) 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. # Build and return the fully static system prompt.
# Memory and current date are injected per-turn via DynamicContextMiddleware # Memory and current date are injected per-turn via DynamicContextMiddleware
# as a <system-reminder> in the first HumanMessage, keeping this prompt # 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, deferred_tools_section=deferred_tools_section,
subagent_section=subagent_section, subagent_section=subagent_section,
subagent_reminder=subagent_reminder, subagent_reminder=subagent_reminder,
skill_first_reminder=skill_first_reminder,
subagent_thinking=subagent_thinking, subagent_thinking=subagent_thinking,
acp_section=acp_and_mounts_section, 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 langchain_core.runnables import RunnableConfig
from deerflow.agents.lead_agent.agent import build_middlewares 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.agents.thread_state import ThreadState
from deerflow.config.agents_config import AGENT_NAME_PATTERN 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 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.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.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.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.skills.storage import get_or_new_user_skill_storage
from deerflow.tools.builtins.tool_search import assemble_deferred_tools 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 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) 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) 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] = { kwargs: dict[str, Any] = {
# attach_tracing=False because ``stream()`` injects tracing # attach_tracing=False because ``stream()`` injects tracing
# callbacks at the graph invocation root so a single embedded run # callbacks at the graph invocation root so a single embedded run
@ -281,6 +295,7 @@ class DeerFlowClient:
app_config=self._app_config, app_config=self._app_config,
deferred_names=deferred_setup.deferred_names, deferred_names=deferred_setup.deferred_names,
user_id=get_effective_user_id(), user_id=get_effective_user_id(),
skill_names=skill_setup.skill_names or None,
), ),
"state_schema": ThreadState, "state_schema": ThreadState,
} }

View File

@ -29,6 +29,10 @@ class SkillsConfig(BaseModel):
default=DEFAULT_SKILLS_CONTAINER_PATH, default=DEFAULT_SKILLS_CONTAINER_PATH,
description="Path where skills are mounted in the sandbox container", 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: def get_skills_path(self) -> Path:
""" """

View File

@ -1,5 +1,7 @@
from __future__ import annotations 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 .installer import SkillAlreadyExistsError, SkillSecurityScanError
from .storage import LocalSkillStorage, SkillStorage, get_or_new_skill_storage from .storage import LocalSkillStorage, SkillStorage, get_or_new_skill_storage
from .types import Skill from .types import Skill
@ -7,6 +9,10 @@ from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmat
__all__ = [ __all__ = [
"Skill", "Skill",
"SkillCatalog",
"SkillSearchSetup",
"build_describe_skill_tool",
"build_skill_search_setup",
"ALLOWED_FRONTMATTER_PROPERTIES", "ALLOWED_FRONTMATTER_PROPERTIES",
"_validate_skill_frontmatter", "_validate_skill_frontmatter",
"SkillAlreadyExistsError", "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) 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. """Parse the optional allowed-tools frontmatter field.
Returns None when the field is omitted. Returns a list when the field is a Returns None when the field is omitted. Returns a tuple when the field is a
YAML sequence of strings, including an empty list for explicit no-tool YAML sequence of strings, including an empty tuple for explicit no-tool
skills. Raises ValueError for malformed values. skills. Raises ValueError for malformed values.
""" """
if raw is None: if raw is None:
@ -63,21 +63,21 @@ def parse_allowed_tools(raw: object, skill_file: Path) -> list[str] | None:
if not tool_name: if not tool_name:
raise ValueError(f"allowed-tools in {skill_file} cannot contain empty tool names") raise ValueError(f"allowed-tools in {skill_file} cannot contain empty tool names")
allowed_tools.append(tool_name) 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). """Parse the optional required-secrets frontmatter field (issue #3861).
Accepts a YAML sequence whose items are either a string (the secret / env 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 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 environment-variable name are dropped with a warning, so one malformed
declaration does not invalidate the whole skill. Raises ValueError only when declaration does not invalidate the whole skill. Raises ValueError only when
the field is present but is not a list. the field is present but is not a list.
""" """
if raw is None: if raw is None:
return [] return ()
if not isinstance(raw, list): if not isinstance(raw, list):
raise ValueError(f"required-secrets in {skill_file} must be a 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 continue
seen.add(name) seen.add(name)
secrets.append(SecretRequirement(name=name, optional=optional)) 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: 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 from __future__ import annotations
import dataclasses
import logging import logging
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@ -267,8 +268,7 @@ class SkillStorage(ABC):
from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.extensions_config import ExtensionsConfig
extensions_config = ExtensionsConfig.from_file() extensions_config = ExtensionsConfig.from_file()
for skill in skills: skills = [dataclasses.replace(s, enabled=extensions_config.is_skill_enabled(s.name, s.category)) for s in skills]
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
except Exception as e: except Exception as e:
logger.warning("Failed to load extensions config: %s", 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 from __future__ import annotations
import asyncio import asyncio
import dataclasses
import json import json
import logging import logging
import os import os
@ -205,12 +206,12 @@ class UserScopedSkillStorage(LocalSkillStorage):
from deerflow.config.extensions_config import get_extensions_config from deerflow.config.extensions_config import get_extensions_config
extensions_config = get_extensions_config() extensions_config = get_extensions_config()
for skill in skills: skills = [
category = skill.category.value if hasattr(skill.category, "value") else skill.category 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 category != SkillCategory.PUBLIC.value: 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
per_user_state = self.get_skill_enabled_state(skill.name) else s
global_state = extensions_config.is_skill_enabled(skill.name, category) for s in skills
skill.enabled = per_user_state and global_state ]
if enabled_only: if enabled_only:
skills = [s for s in skills if s.enabled] skills = [s for s in skills if s.enabled]

View File

@ -35,7 +35,7 @@ class SecretRequirement:
optional: bool = False optional: bool = False
@dataclass @dataclass(frozen=True)
class Skill: class Skill:
"""Represents a skill with its metadata and file path""" """Represents a skill with its metadata and file path"""
@ -46,9 +46,9 @@ class Skill:
skill_file: Path skill_file: Path
relative_path: Path # Relative path from category root to skill directory relative_path: Path # Relative path from category root to skill directory
category: SkillCategory # 'public' or 'custom' 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 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 @property
def skill_path(self) -> str: 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.get_model_config.return_value = MagicMock(supports_vision=False)
config_mock.checkpointer = None config_mock.checkpointer = None
config_mock.skills.deferred_discovery = False
config_mock.skills.container_path = "/mnt/skills"
config_mock.tool_search.enabled = False
with ( with (
patch("deerflow.client.get_app_config", return_value=config_mock), patch("deerflow.client.get_app_config", return_value=config_mock),
patch("deerflow.client.create_agent", side_effect=fake_create_agent), patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.create_chat_model", return_value=MagicMock()), patch("deerflow.client.create_chat_model", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", 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=[]), patch("deerflow.client.DeerFlowClient._get_tools", return_value=[]),
): ):
client = DeerFlowClient(checkpointer=None) client = DeerFlowClient(checkpointer=None)
@ -930,12 +935,17 @@ class TestClientCheckpointerFallback:
config_mock.get_model_config.return_value = MagicMock(supports_vision=False) config_mock.get_model_config.return_value = MagicMock(supports_vision=False)
config_mock.checkpointer = None config_mock.checkpointer = None
config_mock.skills.deferred_discovery = False
config_mock.skills.container_path = "/mnt/skills"
config_mock.tool_search.enabled = False
with ( with (
patch("deerflow.client.get_app_config", return_value=config_mock), patch("deerflow.client.get_app_config", return_value=config_mock),
patch("deerflow.client.create_agent", side_effect=fake_create_agent), patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.create_chat_model", return_value=MagicMock()), patch("deerflow.client.create_chat_model", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", 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=[]), patch("deerflow.client.DeerFlowClient._get_tools", return_value=[]),
): ):
client = DeerFlowClient(checkpointer=explicit_cp) client = DeerFlowClient(checkpointer=explicit_cp)

View File

@ -41,6 +41,9 @@ def mock_app_config():
config = MagicMock() config = MagicMock()
config.models = [model] config.models = [model]
config.token_usage.enabled = False config.token_usage.enabled = False
config.skills.deferred_discovery = False
config.skills.container_path = "/mnt/skills"
config.tool_search.enabled = False
return config return config
@ -916,6 +919,7 @@ class TestEnsureAgent:
patch("deerflow.client.create_agent", return_value=mock_agent), patch("deerflow.client.create_agent", return_value=mock_agent),
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares, 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.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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()), 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.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"), 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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=mock_checkpointer), 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.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", side_effect=fake_build_middlewares), patch("deerflow.client.build_middlewares", side_effect=fake_build_middlewares),
patch("deerflow.client.apply_prompt_template", return_value="prompt"), 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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()), 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.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"), 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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None), patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
): ):
@ -1004,6 +1011,82 @@ class TestEnsureAgent:
# Should still be the same mock — no recreation # Should still be the same mock — no recreation
assert client._agent is mock_agent 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 # get_model
@ -1997,6 +2080,7 @@ class TestScenarioAgentRecreation:
patch("deerflow.client.create_agent", side_effect=fake_create_agent), patch("deerflow.client.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"), 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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()), 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.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"), 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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()), 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.create_agent", side_effect=fake_create_agent),
patch("deerflow.client.build_middlewares", return_value=[]), patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"), 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.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()), 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"), skill_file=Path("/tmp/s/SKILL.md"),
relative_path=Path("s"), relative_path=Path("s"),
category="public", category="public",
allowed_tools=allowed_tools, allowed_tools=tuple(allowed_tools) if allowed_tools is not None else None,
enabled=True, 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): def load_skills(*, enabled_only):
nonlocal load_count nonlocal load_count
load_count += 1 if enabled_only:
assert enabled_only is True load_count += 1
return [make_skill("cached-skill")] return [make_skill("cached-skill")]
return SimpleNamespace(load_skills=load_skills) return SimpleNamespace(load_skills=load_skills)
@ -417,5 +417,52 @@ def test_system_prompt_template_preserves_placeholders():
"{subagent_section}", "{subagent_section}",
"{acp_section}", "{acp_section}",
"{subagent_reminder}", "{subagent_reminder}",
"{skill_first_reminder}",
): ):
assert ph in template, f"placeholder {ph} accidentally removed" 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"), skill_file=Path(f"/tmp/{name}/SKILL.md"),
relative_path=Path(name), relative_path=Path(name),
category="public", category="public",
allowed_tools=allowed_tools, allowed_tools=tuple(allowed_tools) if allowed_tools is not None else None,
enabled=True, 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 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): def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch):
from unittest.mock import MagicMock 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 = MagicMock()
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False) 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) monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}) 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(): 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"}}) 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): 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"}}) 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): 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_file = self._write_skill(tmp_path, "name: erp-report\ndescription: Pull an ERP report")
skill = parse_skill_file(skill_file, SkillCategory.CUSTOM) skill = parse_skill_file(skill_file, SkillCategory.CUSTOM)
assert skill is not None assert skill is not None
assert skill.required_secrets == [] assert skill.required_secrets == ()
def test_string_list_form(self, tmp_path): def test_string_list_form(self, tmp_path):
from deerflow.skills.parser import parse_skill_file 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_file = _write_skill(tmp_path, 'name: my-skill\ndescription: Test\nallowed-tools: ["bash", "read_file"]')
skill = parse_skill_file(skill_file, category="custom") skill = parse_skill_file(skill_file, category="custom")
assert skill is not None 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): def test_parse_empty_allowed_tools_list(tmp_path):
skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test\nallowed-tools: []") skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test\nallowed-tools: []")
skill = parse_skill_file(skill_file, category="custom") skill = parse_skill_file(skill_file, category="custom")
assert skill is not None assert skill is not None
assert skill.allowed_tools == [] assert skill.allowed_tools == ()
def test_parse_invalid_allowed_tools_returns_none(tmp_path): 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): def test_resolve_slash_skill_rejects_disabled_skills(tmp_path):
skill = _make_skill(tmp_path, "data-analysis") import dataclasses
skill.enabled = False
skill = dataclasses.replace(_make_skill(tmp_path, "data-analysis"), enabled=False)
assert resolve_slash_skill("/data-analysis run", [skill]) is None 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): def test_skill_activation_middleware_returns_clear_error_for_disabled_skill(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis") import dataclasses
skill.enabled = False
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])) monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware() middleware = SkillActivationMiddleware()

View File

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

View File

@ -1180,6 +1180,13 @@ skills:
# Default: /mnt/skills # Default: /mnt/skills
container_path: /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, # 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`): # 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) # - Omitted or null: load all globally enabled skills (default)