diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 07d3749b..8a57c47e 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -394,7 +394,17 @@ def get_skills_prompt_section(available_skills: set[str] | None = None) -> str: Returns the ... block listing all enabled skills, suitable for injection into any agent's system prompt. """ - skills = _get_enabled_skills() + thread_id = None + try: + from langgraph.config import get_config + + config = get_config() + thread_id = config.get("configurable", {}).get("thread_id") + except Exception: + pass + + # Keep regular enable/disable behavior while letting uploads be default-enabled in loader. + skills = load_skills(enabled_only=True, thread_id=thread_id) try: from deerflow.config import get_app_config @@ -561,4 +571,6 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen acp_section=acp_and_mounts_section, ) + logger.debug("Generated full system prompt:\n%s", prompt) + return prompt + f"\n{datetime.now().strftime('%Y-%m-%d, %A')}" diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index e7a48d16..0dbc7854 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -192,8 +192,8 @@ class ExtensionsConfig(BaseModel): """ skill_config = self.skills.get(skill_name) if skill_config is None: - # Default to enable for public & custom skill - return skill_category in ("public", "custom") + # Default to enable for public/custom/uploads skills. + return skill_category in ("public", "custom", "uploads") return skill_config.enabled diff --git a/backend/packages/harness/deerflow/skills/loader.py b/backend/packages/harness/deerflow/skills/loader.py index b596d62b..b0d72b92 100644 --- a/backend/packages/harness/deerflow/skills/loader.py +++ b/backend/packages/harness/deerflow/skills/loader.py @@ -8,6 +8,27 @@ from .types import Skill logger = logging.getLogger(__name__) +UPLOADS_SKILLS_PATH = Path("/mnt/user-data/uploads") + + +def get_uploads_skills_path(thread_id: str | None = None) -> Path: + """Resolve the uploads skills root for the current execution context. + + When called from the LangGraph process, uploaded skills live under the + host-side per-thread data directory rather than the sandbox mount path. + """ + if not thread_id: + return UPLOADS_SKILLS_PATH + + try: + from deerflow.config.paths import get_paths + + return get_paths().sandbox_uploads_dir(thread_id) + except Exception as exc: + logger.warning("Failed to resolve uploads skills path for thread %s: %s", thread_id, exc) + return UPLOADS_SKILLS_PATH + + def get_skills_root_path() -> Path: """ Get the root path of the skills directory. @@ -22,12 +43,19 @@ def get_skills_root_path() -> Path: return skills_dir -def load_skills(skills_path: Path | None = None, use_config: bool = True, enabled_only: bool = False) -> list[Skill]: +def load_skills( + skills_path: Path | None = None, + use_config: bool = True, + enabled_only: bool = False, + thread_id: str | None = None, +) -> list[Skill]: """ Load all skills from the skills directory. - Scans both public and custom skill directories, parsing SKILL.md files - to extract metadata. The enabled state is determined by the skills_state_config.json file. + Scans public/custom skill directories under the configured skills root, + and also scans uploaded skills under /mnt/user-data/uploads. + SKILL.md metadata is parsed and enabled state is derived from + skills_state_config.json. Args: skills_path: Optional custom path to skills directory. @@ -35,6 +63,8 @@ def load_skills(skills_path: Path | None = None, use_config: bool = True, enable Otherwise defaults to deer-flow/skills use_config: Whether to load skills path from config (default: True) enabled_only: If True, only return enabled skills (default: False) + thread_id: Optional thread ID used to resolve per-thread uploads skills + from the LangGraph host process Returns: List of Skill objects, sorted by name @@ -57,12 +87,22 @@ def load_skills(skills_path: Path | None = None, use_config: bool = True, enable skills = [] - # Scan public and custom directories - for category in ["public", "custom"]: - category_path = skills_path / category + # Scan built-in roots and uploaded skills mounted in personal workspace. + scan_targets: list[tuple[str, Path]] = [ + ("public", skills_path / "public"), + ("custom", skills_path / "custom"), + ("uploads", get_uploads_skills_path(thread_id)), + ] + + for category, category_path in scan_targets: + logger.debug("Scanning %s skills under %s", category, category_path) + if not category_path.exists() or not category_path.is_dir(): + logger.debug("Skip %s scan: directory not found or not a directory (%s)", category, category_path) continue + scanned_skill_dirs: list[str] = [] + for current_root, dir_names, file_names in os.walk(category_path, followlinks=True): # Keep traversal deterministic and skip hidden directories. dir_names[:] = sorted(name for name in dir_names if not name.startswith(".")) @@ -71,11 +111,22 @@ def load_skills(skills_path: Path | None = None, use_config: bool = True, enable skill_file = Path(current_root) / "SKILL.md" relative_path = skill_file.parent.relative_to(category_path) + scanned_skill_dirs.append(relative_path.as_posix()) skill = parse_skill_file(skill_file, category=category, relative_path=relative_path) if skill: skills.append(skill) + if scanned_skill_dirs: + logger.debug( + "%s scan found %d skill directories: %s", + category, + len(scanned_skill_dirs), + ", ".join(sorted(scanned_skill_dirs)), + ) + else: + logger.debug("%s scan found no skill directories", category) + # Load skills state configuration and update enabled status # NOTE: We use ExtensionsConfig.from_file() instead of get_extensions_config() # to always read the latest configuration from disk. This ensures that changes @@ -86,6 +137,10 @@ def load_skills(skills_path: Path | None = None, use_config: bool = True, enable extensions_config = ExtensionsConfig.from_file() for skill in skills: + if skill.category == "uploads": + # Uploaded skills should be available by default for the current thread. + skill.enabled = True + continue skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category) except Exception as e: # If config loading fails, default to all enabled diff --git a/backend/packages/harness/deerflow/skills/parser.py b/backend/packages/harness/deerflow/skills/parser.py index d2a3af67..f22f2b60 100644 --- a/backend/packages/harness/deerflow/skills/parser.py +++ b/backend/packages/harness/deerflow/skills/parser.py @@ -13,7 +13,7 @@ def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None Args: skill_file: Path to the SKILL.md file - category: Category of the skill ('public' or 'custom') + category: Category of the skill ('public', 'custom', or 'uploads') Returns: Skill object if parsing succeeds, None otherwise diff --git a/backend/packages/harness/deerflow/skills/types.py b/backend/packages/harness/deerflow/skills/types.py index 0cdb668f..57ba0ae0 100644 --- a/backend/packages/harness/deerflow/skills/types.py +++ b/backend/packages/harness/deerflow/skills/types.py @@ -12,7 +12,7 @@ class Skill: skill_dir: Path skill_file: Path relative_path: Path # Relative path from category root to skill directory - category: str # 'public' or 'custom' + category: str # 'public', 'custom', or 'uploads' enabled: bool = False # Whether this skill is enabled @property @@ -31,7 +31,10 @@ class Skill: Returns: Full container path to the skill directory """ - category_base = f"{container_base_path}/{self.category}" + if self.category == "uploads": + category_base = "/mnt/user-data/uploads" + else: + category_base = f"{container_base_path}/{self.category}" skill_path = self.skill_path if skill_path: return f"{category_base}/{skill_path}"