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