Tianye Song 15454b6fec
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>
2026-07-04 23:09:29 +08:00

181 lines
6.7 KiB
Python

"""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>"""