Enhance skills management by integrating project-scoped skills and updating skill search functionality. Adjusted skills tool to utilize project context for skill discovery and refined the skills loading process. Updated documentation to reflect changes in skill operations and removed deprecated script execution methods. Uses code_execution_tool for skill scripts instead of execute_scripts from the skills_tool.

This commit is contained in:
TerminallyLazy 2026-01-31 04:06:36 -05:00
parent 95de41a99e
commit 3830c64be0
9 changed files with 176 additions and 392 deletions

View file

@ -6,6 +6,7 @@ from agent import LoopData
from python.helpers.memory import Memory
from python.helpers import files
from python.helpers import skills as skills_helper
from python.helpers import projects
class RecallSkills(Extension):
@ -29,6 +30,9 @@ class RecallSkills(Extension):
if not user_instruction or len(user_instruction) < 8:
return
# Get active project for project-scoped skill discovery
project_name = projects.get_context_project_name(self.agent.context) if self.agent.context else None
try:
db = await Memory.get(self.agent)
docs = await db.search_similarity_threshold(
@ -56,8 +60,8 @@ class RecallSkills(Extension):
break
if not recalled:
# cheap lexical fallback
matches = skills_helper.search_skills(user_instruction, limit=6)
# cheap lexical fallback (includes project skills when project is active)
matches = skills_helper.search_skills(user_instruction, limit=6, project_name=project_name)
for s in matches:
recalled.append(str(s.skill_md_path))
@ -75,7 +79,7 @@ class RecallSkills(Extension):
text = abs_path.read_text(encoding="utf-8", errors="replace")
fm, body = skills_helper.split_frontmatter(text)
# Infer source if possible (custom/builtin/shared), else "unknown"
# Infer source if possible (custom/builtin/shared/project), else "unknown"
source = "unknown"
try:
rel = abs_path.resolve().relative_to(base_skills_dir)
@ -83,6 +87,14 @@ class RecallSkills(Extension):
source = rel.parts[0]
except Exception:
pass
if source == "unknown" and project_name:
try:
from python.helpers.skills_import import get_project_skills_folder
proj_skills = get_project_skills_folder(project_name)
abs_path.resolve().relative_to(proj_skills.resolve())
source = "project"
except Exception:
pass
name = str(fm.get("name") or abs_path.parent.name).strip()
desc = str(fm.get("description") or "").strip()

View file

@ -335,15 +335,32 @@ class Memory:
filename_pattern="**/SKILL.md",
)
# load project-scoped skills from all projects
try:
from python.helpers.skills_import import get_project_skills_folder
from python.helpers import projects as projects_helper
for proj in projects_helper.get_active_projects_list():
proj_skills_path = str(get_project_skills_folder(proj["name"]))
if os.path.isdir(proj_skills_path):
index = knowledge_import.load_knowledge(
log_item,
proj_skills_path,
index,
{"area": Memory.Area.SKILLS.value},
filename_pattern="**/SKILL.md",
)
except Exception:
pass
# load custom instruments descriptions
index = knowledge_import.load_knowledge(
log_item,
files.get_abs_path("usr/instruments"),
index,
{"area": Memory.Area.INSTRUMENTS.value},
filename_pattern="**/*.md",
recursive=True,
)
# index = knowledge_import.load_knowledge(
# log_item,
# files.get_abs_path("usr/instruments"),
# index,
# {"area": Memory.Area.INSTRUMENTS.value},
# filename_pattern="**/*.md",
# recursive=True,
# )
return index

View file

@ -19,7 +19,7 @@ def migrate_user_data() -> None:
_move_dir("tmp/downloads", "usr/downloads")
_move_dir("tmp/email", "usr/email")
_move_dir("knowledge/custom", "usr/knowledge", overwrite=True)
_move_dir("instruments/custom", "usr/instruments", overwrite=True)
_move_dir("skills/custom", "usr/skills", overwrite=True)
# --- Migrate Files -------------------------------------------------------------
# Move specific configuration files to usr/
@ -37,7 +37,7 @@ def migrate_user_data() -> None:
# We use _merge_dir_contents because we want to move the *contents* of default/
# into the parent directory, not move the default directory itself.
_merge_dir_contents("knowledge/default", "knowledge")
_merge_dir_contents("instruments/default", "instruments")
_merge_dir_contents("skills/default", "skills")
# --- Cleanup -------------------------------------------------------------------
@ -103,7 +103,7 @@ def _cleanup_obsolete() -> None:
"""
to_remove = [
"knowledge/default",
"instruments/default",
"skills/default",
"memory"
]
for path in to_remove:

View file

@ -14,7 +14,7 @@ except Exception: # pragma: no cover
yaml = None # type: ignore
SkillSource = Literal["custom", "builtin", "shared"]
SkillSource = Literal["custom", "builtin", "shared", "project"]
@dataclass(slots=True)
@ -42,10 +42,25 @@ def get_skills_base_dir() -> Path:
return Path(files.get_abs_path("skills"))
def get_skill_roots(order: Optional[List[SkillSource]] = None) -> List[Tuple[SkillSource, Path]]:
def get_skill_roots(
order: Optional[List[SkillSource]] = None,
project_name: Optional[str] = None,
) -> List[Tuple[SkillSource, Path]]:
base = get_skills_base_dir()
order = order or ["custom", "builtin", "shared"]
return [(src, base / src) for src in order]
roots: List[Tuple[SkillSource, Path]] = [(src, base / src) for src in order]
# Include project-scoped skills if a project is active
if project_name:
try:
from python.helpers.skills_import import get_project_skills_folder
project_skills = get_project_skills_folder(project_name)
if project_skills.exists():
roots.insert(0, ("project", project_skills))
except Exception:
pass
return roots
def _is_hidden_path(path: Path) -> bool:
@ -254,10 +269,11 @@ def list_skills(
include_content: bool = False,
dedupe: bool = True,
root_order: Optional[List[SkillSource]] = None,
project_name: Optional[str] = None,
) -> List[Skill]:
skills: List[Skill] = []
roots = get_skill_roots(order=root_order)
roots = get_skill_roots(order=root_order, project_name=project_name)
for source, root in roots:
for skill_md in discover_skill_md_files(root):
s = skill_from_markdown(skill_md, source, include_content=include_content)
@ -281,12 +297,13 @@ def find_skill(
*,
include_content: bool = False,
root_order: Optional[List[SkillSource]] = None,
project_name: Optional[str] = None,
) -> Optional[Skill]:
target = _normalize_name(skill_name)
if not target:
return None
roots = get_skill_roots(order=root_order)
roots = get_skill_roots(order=root_order, project_name=project_name)
for source, root in roots:
for skill_md in discover_skill_md_files(root):
s = skill_from_markdown(skill_md, source, include_content=include_content)
@ -297,13 +314,13 @@ def find_skill(
return None
def search_skills(query: str, *, limit: int = 25) -> List[Skill]:
def search_skills(query: str, *, limit: int = 25, project_name: Optional[str] = None) -> List[Skill]:
q = (query or "").strip().lower()
if not q:
return []
terms = [t for t in re.split(r"\s+", q) if t]
candidates = list_skills(include_content=False, dedupe=True)
candidates = list_skills(include_content=False, dedupe=True, project_name=project_name)
scored: List[Tuple[int, Skill]] = []
for s in candidates:

View file

@ -1,13 +1,11 @@
from __future__ import annotations
import json
import re
import shlex
from pathlib import Path
from typing import Any, Dict, List
from typing import List
from python.helpers.tool import Tool, Response
from python.helpers import files
from python.helpers import projects
from python.helpers import skills as skills_helper
@ -20,18 +18,14 @@ class SkillsTool(Tool):
- search (query)
- load (skill_name)
- read_file (skill_name, file_path)
- execute_script (skill_name, script_path, script_args, arg_style)
arg_style options for execute_script:
- "positional" (default): Pass values as positional args (sys.argv[1], sys.argv[2])
Example: {"input": "file.pdf", "output": "/tmp"} sys.argv = ['script.py', 'file.pdf', '/tmp']
- "named": Pass as --key value pairs (for argparse/click scripts)
Example: {"input": "file.pdf", "output": "/tmp"} sys.argv = ['script.py', '--input', 'file.pdf', '--output', '/tmp']
- "env": Only use environment variables (SKILL_ARG_INPUT, SKILL_ARG_OUTPUT)
Environment variables (SKILL_ARG_*) are always set regardless of arg_style.
Script execution is handled by code_execution_tool directly.
"""
def _get_project_name(self) -> str | None:
ctx = getattr(self.agent, "context", None)
return projects.get_context_project_name(ctx) if ctx else None
async def execute(self, **kwargs) -> Response:
method = (
(kwargs.get("method") or self.args.get("method") or self.method or "")
@ -52,20 +46,11 @@ class SkillsTool(Tool):
skill_name = str(kwargs.get("skill_name") or "").strip()
file_path = str(kwargs.get("file_path") or "").strip()
return Response(message=self._read_file(skill_name, file_path), break_loop=False)
if method == "execute_script":
skill_name = str(kwargs.get("skill_name") or "").strip()
script_path = str(kwargs.get("script_path") or "").strip()
script_args = kwargs.get("script_args") or {}
if not isinstance(script_args, dict):
script_args = {}
# arg_style: "positional" (default), "named" (--key value), or "env" (env vars only)
arg_style = str(kwargs.get("arg_style") or "positional").strip().lower()
return await self._execute_script(skill_name, script_path, script_args, arg_style)
return Response(
message=(
"Error: missing/invalid 'method'. Supported methods: "
"list, search, load, read_file, execute_script."
"list, search, load, read_file."
),
break_loop=False,
)
@ -73,7 +58,7 @@ class SkillsTool(Tool):
return Response(message=f"Error in skills_tool: {e}", break_loop=False)
def _list(self) -> str:
skills = skills_helper.list_skills(include_content=False, dedupe=True)
skills = skills_helper.list_skills(include_content=False, dedupe=True, project_name=self._get_project_name())
if not skills:
return "No skills found. Expected SKILL.md files under: skills/{custom,builtin,shared}."
@ -97,7 +82,7 @@ class SkillsTool(Tool):
if not query:
return "Error: 'query' is required for method=search."
results = skills_helper.search_skills(query, limit=25)
results = skills_helper.search_skills(query, limit=25, project_name=self._get_project_name())
if not results:
return f"No skills matched query: {query!r}"
@ -116,7 +101,7 @@ class SkillsTool(Tool):
if not skill_name:
return "Error: 'skill_name' is required for method=load."
skill = skills_helper.find_skill(skill_name, include_content=True)
skill = skills_helper.find_skill(skill_name, include_content=True, project_name=self._get_project_name())
if not skill:
return f"Error: skill not found: {skill_name!r}. Try skills_tool method=list or method=search."
@ -166,7 +151,7 @@ class SkillsTool(Tool):
if not file_path:
return "Error: 'file_path' is required for method=read_file."
skill = skills_helper.find_skill(skill_name, include_content=False)
skill = skills_helper.find_skill(skill_name, include_content=False, project_name=self._get_project_name())
if not skill:
return f"Error: skill not found: {skill_name!r}."
@ -186,134 +171,6 @@ class SkillsTool(Tool):
text = content.decode("utf-8", errors="replace")
return f"File: {file_path}\n\n{text}"
async def _execute_script(
self, skill_name: str, script_path: str, script_args: Dict[str, Any],
arg_style: str = "positional"
) -> Response:
if not skill_name:
return Response(message="Error: 'skill_name' is required for method=execute_script.", break_loop=False)
if not script_path:
return Response(message="Error: 'script_path' is required for method=execute_script.", break_loop=False)
skill = skills_helper.find_skill(skill_name, include_content=False)
if not skill:
return Response(message=f"Error: skill not found: {skill_name!r}.", break_loop=False)
try:
script_abs = skills_helper.safe_path_within_dir(skill.path, script_path)
except Exception as e:
return Response(message=f"Error: invalid script_path: {e}", break_loop=False)
if not script_abs.exists() or not script_abs.is_file():
return Response(message=f"Error: script not found: {script_path!r} (within skill {skill.name})", break_loop=False)
ext = script_abs.suffix.lower()
runtime: str
code: str
# Use /a0 paths for remote (SSH) execution inside the container; use local absolute paths otherwise.
if self.agent.config.code_exec_ssh_enabled:
script_runtime_path = files.normalize_a0_path(str(script_abs))
script_runtime_dir = files.normalize_a0_path(str(script_abs.parent))
else:
script_runtime_path = str(script_abs)
script_runtime_dir = str(script_abs.parent)
# Build environment variables (SKILL_ARG_*) - always set as fallback
env_vars: Dict[str, str] = {}
for k, v in (script_args or {}).items():
env_key = f"SKILL_ARG_{re.sub(r'[^A-Za-z0-9_]', '_', str(k).upper())}"
env_vars[env_key] = str(v)
# Build CLI args based on arg_style:
# - "positional": ['value1', 'value2'] - for scripts using sys.argv[1], sys.argv[2]
# - "named": ['--key1', 'value1', '--key2', 'value2'] - for argparse/click scripts
# - "env": [] - only use environment variables, no CLI args
cli_args: List[str] = []
if arg_style == "positional":
cli_args = [str(v) for v in (script_args or {}).values()]
elif arg_style == "named":
for k, v in (script_args or {}).items():
cli_args.append(f"--{k}")
cli_args.append(str(v))
# "env" style: cli_args stays empty, only env vars are used
if ext == ".py":
runtime = "python"
# Set env vars (always available as fallback)
env_lines = [f"os.environ[{json.dumps(k)}] = {json.dumps(v)}" for k, v in env_vars.items()]
env_setup = "\n".join(env_lines) if env_lines else "pass"
# Set sys.argv: ['script.py', ...cli_args]
argv_list = [script_runtime_path] + cli_args
argv_setup = f"sys.argv = {json.dumps(argv_list)}"
code = (
"import os, sys, runpy\n"
f"os.chdir({json.dumps(script_runtime_dir)})\n"
f"{env_setup}\n"
f"{argv_setup}\n"
f"runpy.run_path({json.dumps(script_runtime_path)}, run_name='__main__')\n"
)
elif ext == ".js":
runtime = "nodejs"
# Set process.env (always available as fallback)
env_lines = [f"process.env[{json.dumps(k)}] = {json.dumps(v)};" for k, v in env_vars.items()]
env_setup = "\n".join(env_lines) if env_lines else ""
# Node.js argv: ['node', 'script.js', ...cli_args]
argv_list = ["node", script_runtime_path] + cli_args
code = (
f"process.chdir({json.dumps(script_runtime_dir)});\n"
f"{env_setup}\n"
f"process.argv = {json.dumps(argv_list)};\n"
f"require({json.dumps(script_runtime_path)});\n"
)
elif ext == ".sh":
runtime = "terminal"
# Environment variables (always available as fallback)
env_parts = [f"{k}={shlex.quote(v)}" for k, v in env_vars.items()]
env_prefix = " ".join(env_parts)
# Pass CLI args to script
cli_args_str = " ".join(shlex.quote(a) for a in cli_args)
cd_cmd = f"cd {shlex.quote(script_runtime_dir)}"
run_cmd = f"bash {shlex.quote(script_runtime_path)}"
if cli_args_str:
run_cmd = f"{run_cmd} {cli_args_str}"
if env_prefix:
code = f"{cd_cmd} && {env_prefix} {run_cmd}"
else:
code = f"{cd_cmd} && {run_cmd}"
else:
return Response(
message=f"Error: unsupported script type {ext!r}. Supported: .py, .js, .sh",
break_loop=False,
)
# Delegate actual execution to code_execution_tool (sandboxed)
from python.tools.code_execution_tool import CodeExecution
cet = CodeExecution(
agent=self.agent,
name="code_execution_tool",
method=None,
args={
"runtime": runtime,
"code": code,
"session": int(self.args.get("session", 0) or 0),
},
message=self.message,
loop_data=self.loop_data,
)
# Must call before_execution to initialize self.log before execute()
await cet.before_execution(**cet.args)
resp = await cet.execute(**cet.args)
# Wrap result to make it clear it was a skill script
wrapped = (
f"Executed script: {skill.name}/{script_path}\n"
f"Runtime: {runtime}\n\n"
f"{resp.message}"
)
return Response(message=wrapped, break_loop=False)
def _list_skill_files(self, skill_dir: Path, *, max_files: int = 80) -> List[str]:
if not skill_dir.exists():
return []