mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-08 01:41:42 +00:00
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:
parent
95de41a99e
commit
3830c64be0
9 changed files with 176 additions and 392 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue