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

78 lines
2.9 KiB
Python

import os
from pathlib import Path
from pydantic import BaseModel, Field
from deerflow.config.runtime_paths import project_root, resolve_path
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
def _legacy_skills_candidates() -> tuple[Path, ...]:
"""Return source-tree skills locations for monorepo compatibility."""
backend_dir = Path(__file__).resolve().parents[4]
repo_root = backend_dir.parent
return (repo_root / "skills",)
class SkillsConfig(BaseModel):
"""Configuration for skills system"""
use: str = Field(
default="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
description="Class path of the SkillStorage implementation.",
)
path: str | None = Field(
default=None,
description=("Path to skills directory. If not specified, defaults to `skills` under the caller project root, falling back to the legacy repo-root location for monorepo compatibility."),
)
container_path: str = Field(
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:
"""
Get the resolved skills directory path.
Resolution order:
1. Explicit ``path`` field
2. ``DEER_FLOW_SKILLS_PATH`` environment variable
3. ``skills`` under the caller project root (``project_root()``)
4. Legacy repo-root candidates for monorepo compatibility (``_legacy_skills_candidates``)
When none of (3) or (4) exist on disk, the project-root default is returned so callers
can still surface a stable "no skills" location without raising.
"""
if self.path:
# Use configured path (can be absolute or relative to project root)
return resolve_path(self.path)
if env_path := os.getenv("DEER_FLOW_SKILLS_PATH"):
return resolve_path(env_path)
project_default = project_root() / "skills"
if project_default.is_dir():
return project_default
for candidate in _legacy_skills_candidates():
if candidate.is_dir():
return candidate
return project_default
def get_skill_container_path(self, skill_name: str, category: str = "public") -> str:
"""
Get the full container path for a specific skill.
Args:
skill_name: Name of the skill (directory name)
category: Category of the skill (public or custom)
Returns:
Full path to the skill in the container
"""
return f"{self.container_path}/{category}/{skill_name}"