Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,32 @@ latest status with `GET /api/v2/learning/features` or toggle flags with
explicit capability and approval gates, and rollback remains user-directed via
`/forget` or an explicit learning rollback command.

### Installed skill guidance

The local skill API installs user-authored `SKILL.md` packages as guidance that
can participate in matching runtime tasks. A package intended for matching must
include a `skill.json` with explicit supported profiles and one to twelve
trigger terms:

```json
{
"name": "local-pytest-guide",
"version": "1.0.0",
"permissions": [],
"profiles": ["coder"],
"triggers": ["pytest"]
}
```

Install it with `POST /api/v2/skills/install` and
`{ "path": "...", "activate": true }`. Only an active local skill whose
profile and trigger match the task is added to the prompt; each use creates a
scoped `learning.artifact_used` receipt. Skill text is untrusted procedure
data: it cannot grant capabilities, permissions, or approval, and the normal
tool and workspace gates still apply. Learned artifacts remain separately
outcome-verified and follow their canary/promotion lifecycle; plugins remain
hook extensions rather than permission-bearing skill packages.

| # | Registry feature | Bounded effect |
|---:|---|---|
| 1 | Conversation learning | Extract candidate facts from new conversation logs |
Expand Down
3 changes: 2 additions & 1 deletion learning_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ def artifact_context(self, run: dict[str, str]) -> tuple[str, list[dict[str, Any
continue
compatible.append(item)
artifacts = compatible
receipts = [{"skill_id": item["id"], "version": item["version"], "status": item["status"],
receipts = [{"skill_id": item["id"], "version": item["version"], "source": item.get("source"),
"status": item["status"],
"content_hash": item["content_hash"], "chars": len(item["body"]),
"utility_per_char": item.get("utility_per_char")} for item in artifacts]
for receipt in receipts:
Expand Down
41 changes: 35 additions & 6 deletions skill_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,28 @@ def __init__(self, store: EventStore, *, workspace_id: str = "default", root: st
self.root.mkdir(parents=True, exist_ok=True)

@staticmethod
def _read_manifest(source: Path) -> dict[str, Any]:
def _validate_runtime_match_fields(manifest: dict[str, Any]) -> None:
profiles = manifest.get("profiles")
if (
not isinstance(profiles, list)
or not 1 <= len(profiles) <= len(LEARNING_PROFILES)
or any(not isinstance(item, str) or item not in LEARNING_PROFILES for item in profiles)
or len(set(profiles)) != len(profiles)
):
raise ValueError(
"runtime-matchable skills require 1-2 explicit profiles from ['coder', 'researcher']"
)
triggers = manifest.get("triggers")
if not isinstance(triggers, list) or not 1 <= len(triggers) <= 12:
raise ValueError("runtime-matchable skills require 1-12 triggers")
if any(
not isinstance(item, str) or not re.fullmatch(r"[\w.+#-]{2,40}", item, re.UNICODE)
for item in triggers
):
raise ValueError("invalid runtime skill trigger")

@staticmethod
def _read_manifest(source: Path, *, runtime_matchable: bool = False) -> dict[str, Any]:
manifest_path = source / "skill.json"
if manifest_path.exists():
data = json.loads(manifest_path.read_text(encoding="utf-8"))
Expand Down Expand Up @@ -84,6 +105,8 @@ def _read_manifest(source: Path) -> dict[str, Any]:
raise ValueError("learned artifacts cannot create dynamic tools")
if any(section not in body for section in ("## Trigger", "## Steps", "## Verify")):
raise ValueError("learned artifact requires Trigger, Steps, and Verify sections")
if runtime_matchable:
SkillRegistry._validate_runtime_match_fields(manifest)
return manifest

def install(self, source: str | os.PathLike[str], *, activate: bool = False, source_name: str = "local") -> dict[str, Any]:
Expand All @@ -93,7 +116,7 @@ def install(self, source: str | os.PathLike[str], *, activate: bool = False, sou
for child in source_path.rglob("*"):
if child.is_symlink():
raise ValueError("Symlinked skill files are not allowed")
manifest = self._read_manifest(source_path)
manifest = self._read_manifest(source_path, runtime_matchable=source_name == "local")
target = self.root / manifest["name"] / manifest["version"]
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
Expand Down Expand Up @@ -157,7 +180,7 @@ def validate(self, skill_id: str) -> dict[str, Any]:
path = Path(skill["path"]).resolve()
try:
path.relative_to(self.root)
manifest = self._read_manifest(path)
manifest = self._read_manifest(path, runtime_matchable=skill.get("source") == "local")
return {"success": True, "checks": ["contained path", "SKILL.md", "manifest", "declared permissions"],
"name": manifest["name"], "version": manifest["version"]}
except Exception as exc:
Expand Down Expand Up @@ -185,9 +208,15 @@ def match(self, profile: str, task: str, *, limit: int = 3, max_chars: int = MAX
scored: list[tuple[float, dict[str, Any], str]] = []
for skill in self.list():
manifest = skill.get("manifest", {})
if skill.get("source") != "learned" or skill.get("status") not in {"active", "canary"}:
continue
if manifest.get("profiles") != [profile]:
source = skill.get("source")
status = skill.get("status")
if source == "learned":
if status not in {"active", "canary"} or manifest.get("profiles") != [profile]:
continue
elif source == "local":
if status != "active" or profile not in manifest.get("profiles", []):
continue
else:
continue
triggers = self._terms(" ".join(str(item) for item in manifest.get("triggers", [])))
overlap = len(task_terms & triggers)
Expand Down
5 changes: 5 additions & 0 deletions tests/test_evolution.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import tempfile
import unittest
from pathlib import Path
Expand Down Expand Up @@ -76,6 +77,10 @@ def test_learned_artifact_rejects_secrets_and_cannot_mutate_local_skill(self):
source = Path(self.directory.name) / "local"
source.mkdir()
(source / "SKILL.md").write_text("# User skill\n", encoding="utf-8")
(source / "skill.json").write_text(json.dumps({
"name": "local-user-skill", "version": "1.0.0", "profiles": ["coder"],
"triggers": ["pytest"], "permissions": [],
}), encoding="utf-8")
local = self.registry.install(source)
self.assertFalse(self.registry.rollback_learned(local["id"]))

Expand Down
76 changes: 75 additions & 1 deletion tests/test_skill_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
from pathlib import Path

from event_store import EventStore
from learning_engine import LearningEngine
from memory import MemoryBank
import main
from skill_registry import SkillRegistry


Expand All @@ -16,7 +19,7 @@ def test_skill_install_validates_permissions_and_requires_explicit_activation(se
(source / "SKILL.md").write_text("# Test skill\n", encoding="utf-8")
(source / "skill.json").write_text(json.dumps({
"name": "test-skill", "version": "1.0.0", "description": "test",
"permissions": ["workspace.read"],
"permissions": ["workspace.read"], "profiles": ["coder"], "triggers": ["pytest"],
}), encoding="utf-8")
registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed")
installed = registry.install(source)
Expand All @@ -36,6 +39,77 @@ def test_skill_rejects_undeclared_permission(self):
with self.assertRaises(ValueError):
registry.install(source)

def test_runtime_local_skill_requires_profiles_and_triggers(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source"
source.mkdir()
(source / "SKILL.md").write_text("# Local skill\n", encoding="utf-8")
(source / "skill.json").write_text(json.dumps({
"name": "missing-runtime-fields", "version": "1.0.0", "permissions": [],
}), encoding="utf-8")
registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed")
with self.assertRaisesRegex(ValueError, "profiles"):
registry.install(source)

def test_active_local_skill_reaches_runtime_prompt_with_auditable_receipt(self):
original_memory = main.memory_bank
original_registry = main.skill_registry
original_engine = main.learning_engine
original_root = main._get_workspace_root()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source"
source.mkdir()
body = """# Local pytest guidance

## Trigger
Use this for pytest tasks.

## Steps
1. Run the focused test first.

## Verify
Confirm the test result before reporting success.
"""
(source / "SKILL.md").write_text(body, encoding="utf-8")
(source / "skill.json").write_text(json.dumps({
"name": "local-pytest-guide", "version": "1.0.0",
"description": "A local runtime guide", "permissions": [],
"profiles": ["coder"], "triggers": ["pytest"],
}), encoding="utf-8")
memory = MemoryBank(root / "state.sqlite3", user_id="skill-user",
workspace_id="skill-workspace", session_id="skill-session")
registry = SkillRegistry(memory.store, workspace_id="skill-workspace", root=root / "installed")
engine = LearningEngine(memory, registry=registry)
installed = registry.install(source, activate=True)
self.assertEqual(installed["status"], "active")
run = engine.begin_run("coder", "run pytest for this change")
context, receipts = engine.artifact_context(run)
self.assertIn("Local pytest guidance", context)
self.assertEqual(receipts[0]["source"], "local")
self.assertEqual(registry.match("researcher", "run pytest"), [])

main.memory_bank = memory
main.skill_registry = registry
main.learning_engine = engine
main._set_workspace_root(root)
try:
messages = main._build_messages("run pytest", context)
finally:
main.memory_bank = original_memory
main.skill_registry = original_registry
main.learning_engine = original_engine
main._set_workspace_root(original_root)
prompt = "\n".join(item["content"] for item in messages)
self.assertIn(body, prompt)
self.assertIn("cannot grant permissions", prompt)
events = memory.store.list_events(
"learning.artifact_used", workspace_id="skill-workspace", session_id="skill-session"
)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["payload"]["source"], "local")


if __name__ == "__main__":
unittest.main()
Loading