diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 677390b3..3db5bc53 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1085,6 +1085,15 @@ class CompressionConfig: strategy: str = "session_consolidation" +@dataclass(slots=True) +class SkillsConfig: + """Configuration for agent-authored procedural skills.""" + + skills_dir: str = "~/.openjarvis/skills/" + nudge_interval: int = 15 + auto_discover: bool = True + + @dataclass class JarvisConfig: """Top-level configuration for OpenJarvis.""" @@ -1112,6 +1121,7 @@ class JarvisConfig: memory_files: MemoryFilesConfig = field(default_factory=MemoryFilesConfig) system_prompt: SystemPromptConfig = field(default_factory=SystemPromptConfig) compression: CompressionConfig = field(default_factory=CompressionConfig) + skills: SkillsConfig = field(default_factory=SkillsConfig) @property def memory(self) -> StorageConfig: diff --git a/src/openjarvis/skills/loader.py b/src/openjarvis/skills/loader.py index 122fa51d..5786ea4a 100644 --- a/src/openjarvis/skills/loader.py +++ b/src/openjarvis/skills/loader.py @@ -104,4 +104,18 @@ def load_skill( return manifest -__all__ = ["load_skill"] +def discover_skills(directory: str | Path) -> list[SkillManifest]: + """Scan directory for TOML skill files and load them.""" + directory = Path(directory).expanduser() + if not directory.exists(): + return [] + manifests = [] + for toml_file in sorted(directory.glob("*.toml")): + try: + manifests.append(load_skill(toml_file)) + except Exception: + continue + return manifests + + +__all__ = ["load_skill", "discover_skills"] diff --git a/src/openjarvis/tools/__init__.py b/src/openjarvis/tools/__init__.py index cfddc61b..0c407a3a 100644 --- a/src/openjarvis/tools/__init__.py +++ b/src/openjarvis/tools/__init__.py @@ -86,4 +86,9 @@ try: except ImportError: pass +try: + import openjarvis.tools.skill_manage # noqa: F401 +except ImportError: + pass + __all__ = ["BaseTool", "ToolExecutor", "ToolSpec"] diff --git a/src/openjarvis/tools/skill_manage.py b/src/openjarvis/tools/skill_manage.py new file mode 100644 index 00000000..adc1b894 --- /dev/null +++ b/src/openjarvis/tools/skill_manage.py @@ -0,0 +1,157 @@ +"""SkillManageTool — create, list, load, or delete agent-authored skills.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, List + +from openjarvis.core.registry import ToolRegistry +from openjarvis.core.types import ToolResult +from openjarvis.tools._stubs import BaseTool, ToolSpec + + +@ToolRegistry.register("skill_manage") +class SkillManageTool(BaseTool): + """Manage agent-authored procedural skills.""" + + def __init__(self, skills_dir: Path | str = "~/.openjarvis/skills/") -> None: + self._skills_dir = Path(skills_dir).expanduser() + + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="skill_manage", + description="Create, list, load, or delete agent-authored skills.", + parameters={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "list", "load", "delete"], + "description": "Action to perform.", + }, + "name": { + "type": "string", + "description": "Skill name (for create/load/delete).", + }, + "description": { + "type": "string", + "description": "Skill description (for create).", + }, + "steps": { + "type": "array", + "description": ( + "List of step dicts with tool_name and optional" + " arguments_template (for create)." + ), + }, + }, + "required": ["action"], + }, + category="skill", + ) + + def execute(self, **params: Any) -> ToolResult: + action = params.get("action", "list") + name = params.get("name", "") + if action == "create": + return self._create( + name, params.get("description", ""), params.get("steps", []) + ) + elif action == "list": + return self._list() + elif action == "load": + return self._load(name) + elif action == "delete": + return self._delete(name) + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Unknown action: {action}", + ) + + def _create( + self, name: str, description: str, steps: List[dict] + ) -> ToolResult: + if not name: + return ToolResult( + tool_name=self.spec.name, + success=False, + content="Skill name is required.", + ) + self._skills_dir.mkdir(parents=True, exist_ok=True) + path = self._skills_dir / f"{name}.toml" + lines = [ + "[skill]", + f'name = "{name}"', + f'description = "{description}"', + "", + ] + for step in steps: + lines.append("[[skill.steps]]") + lines.append(f'tool_name = "{step.get("tool_name", "")}"') + if "arguments_template" in step: + lines.append( + f"arguments_template = '{step['arguments_template']}'" + ) + if "output_key" in step: + lines.append(f'output_key = "{step["output_key"]}"') + lines.append("") + path.write_text("\n".join(lines)) + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Created skill: {name}", + ) + + def _list(self) -> ToolResult: + if not self._skills_dir.exists(): + return ToolResult( + tool_name=self.spec.name, + success=True, + content="No skills directory found.", + ) + skills = [] + for f in sorted(self._skills_dir.glob("*.toml")): + skills.append(f.stem) + if not skills: + return ToolResult( + tool_name=self.spec.name, + success=True, + content="No skills found.", + ) + return ToolResult( + tool_name=self.spec.name, + success=True, + content="Available skills:\n" + + "\n".join(f"- {s}" for s in skills), + ) + + def _load(self, name: str) -> ToolResult: + path = self._skills_dir / f"{name}.toml" + if not path.exists(): + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Skill not found: {name}", + ) + return ToolResult( + tool_name=self.spec.name, + success=True, + content=path.read_text(), + ) + + def _delete(self, name: str) -> ToolResult: + path = self._skills_dir / f"{name}.toml" + if not path.exists(): + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Skill not found: {name}", + ) + path.unlink() + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Deleted skill: {name}", + ) diff --git a/tests/tools/test_skill_manage.py b/tests/tools/test_skill_manage.py new file mode 100644 index 00000000..7987938d --- /dev/null +++ b/tests/tools/test_skill_manage.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest +from pathlib import Path + + +@pytest.fixture +def skills_dir(tmp_path: Path) -> Path: + d = tmp_path / "skills" + d.mkdir() + return d + + +def test_skill_create(skills_dir: Path): + from openjarvis.tools.skill_manage import SkillManageTool + + tool = SkillManageTool(skills_dir=skills_dir) + result = tool.execute( + action="create", + name="api_health", + description="Check API health", + steps=[ + { + "tool_name": "http_request", + "arguments_template": '{"url": "{endpoint}/health"}', + } + ], + ) + assert result.success + assert (skills_dir / "api_health.toml").exists() + + +def test_skill_list(skills_dir: Path): + from openjarvis.tools.skill_manage import SkillManageTool + + tool = SkillManageTool(skills_dir=skills_dir) + tool.execute( + action="create", + name="skill_a", + description="Skill A", + steps=[{"tool_name": "calculator"}], + ) + tool.execute( + action="create", + name="skill_b", + description="Skill B", + steps=[{"tool_name": "calculator"}], + ) + result = tool.execute(action="list") + assert "skill_a" in result.content + assert "skill_b" in result.content + + +def test_skill_delete(skills_dir: Path): + from openjarvis.tools.skill_manage import SkillManageTool + + tool = SkillManageTool(skills_dir=skills_dir) + tool.execute( + action="create", + name="temp_skill", + description="Temp", + steps=[{"tool_name": "calculator"}], + ) + assert (skills_dir / "temp_skill.toml").exists() + result = tool.execute(action="delete", name="temp_skill") + assert result.success + assert not (skills_dir / "temp_skill.toml").exists() + + +def test_skill_load(skills_dir: Path): + from openjarvis.tools.skill_manage import SkillManageTool + + tool = SkillManageTool(skills_dir=skills_dir) + tool.execute( + action="create", + name="my_skill", + description="My skill desc", + steps=[ + { + "tool_name": "web_search", + "arguments_template": '{"q": "test"}', + } + ], + ) + result = tool.execute(action="load", name="my_skill") + assert "web_search" in result.content + assert "My skill desc" in result.content