diff --git a/runtime/prompt_prebuild.py b/runtime/prompt_prebuild.py index abca7364..36c168ec 100644 --- a/runtime/prompt_prebuild.py +++ b/runtime/prompt_prebuild.py @@ -10,6 +10,7 @@ from oclaw.runtime.agent_context import build_role_system_context from oclaw.runtime.agents.specialists import discover_specialist_ids from oclaw.runtime.direct_loop import tool_wire_freeze_status, warm_tool_wire_cache from oclaw.runtime.skill_role_binding import SKILL_ROLE_BINDING_KEY +from oclaw.runtime.skills import workspace_skills_layout_signature from oclaw.runtime.system_prompt import get_executor_prompt_static, warm_executor_prompt_cache from oclaw.runtime.tools.catalog import default_registry from oclaw.runtime.workspaces.experts import expert_workspace_signature_token, list_experts @@ -84,6 +85,7 @@ def get_manager_prompt_prebuild( bool(memory_enabled), expert_workspace_signature_token(), _manager_settings_signature(store), + workspace_skills_layout_signature(), ) with _MANAGER_PREBUILD_CACHE_LOCK: cached = _MANAGER_PREBUILD_CACHE.get(cache_key) diff --git a/runtime/skills.py b/runtime/skills.py index a0a68e53..cef2322f 100644 --- a/runtime/skills.py +++ b/runtime/skills.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import re @@ -170,6 +171,38 @@ def discover_public_workspace_skill_names(skills_root: str | Path | None = None) return out +def workspace_skills_layout_signature(skills_root: str | Path | None = None) -> str: + """Short hex signature that changes when any ``SKILL.md`` under the skills root moves or changes on disk. + + Used to bust executor/manager prompt caches after install/uninstall/edit of workspace skills without + touching SQLite settings. + """ + base = Path(skills_root).resolve() if skills_root else default_skills_root() + if not base.exists() or not base.is_dir(): + return "0" + h = hashlib.sha256() + h.update(str(base).encode("utf-8", errors="ignore")) + h.update(b"\n") + try: + for skill_md in sorted(base.rglob("SKILL.md"), key=lambda p: str(p).lower()): + if not skill_md.is_file(): + continue + try: + st = skill_md.stat() + rel = skill_md.relative_to(base).as_posix().lower() + h.update(rel.encode("utf-8", errors="ignore")) + h.update(str(int(st.st_mtime_ns)).encode("ascii", errors="ignore")) + h.update(b":") + h.update(str(int(st.st_size)).encode("ascii", errors="ignore")) + h.update(b"\n") + except OSError: + h.update(str(skill_md.resolve()).encode("utf-8", errors="ignore")) + h.update(b"\n?") + except OSError: + return "0:err" + return h.hexdigest()[:24] + + def _tool_origin(tool: "ToolSpec") -> str: nm = str(getattr(tool, "name", "") or "") if nm.startswith("mcp__"): @@ -344,6 +377,7 @@ __all__ = [ "build_skill_registry", "default_skills_root", "discover_workspace_skill_manifests", + "workspace_skills_layout_signature", "load_skill_manifest", "materialize_skills_from_tool_specs", "skill_runtime_diagnostics", diff --git a/runtime/system_prompt.py b/runtime/system_prompt.py index 54ea2892..3c353981 100644 --- a/runtime/system_prompt.py +++ b/runtime/system_prompt.py @@ -9,6 +9,7 @@ from oclaw.platform.config.paths import PROJECT_ROOT from oclaw.runtime.memory_stage import render_memory_context_block from oclaw.runtime.project_context_prompt import build_project_context_block from oclaw.runtime.skill_role_binding import SKILL_ROLE_BINDING_KEY +from oclaw.runtime.skills import workspace_skills_layout_signature from oclaw.runtime.skills_prompt import build_skills_catalog_block from oclaw.runtime.skills_workspace_lane import ( fs_safe_workspace_lane_segment, @@ -205,6 +206,7 @@ def get_executor_prompt_static( str(skill_binding_role or "").strip().lower(), expert_workspace_signature_token(), _executor_prompt_settings_signature(store), + workspace_skills_layout_signature(), bool(tools is not None), int(bool(excl)), str(lane_seg or ""), diff --git a/tests/test_oclaw_system_prompt.py b/tests/test_oclaw_system_prompt.py index b72db624..ac5d7f40 100644 --- a/tests/test_oclaw_system_prompt.py +++ b/tests/test_oclaw_system_prompt.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from oclaw.runtime.skills import workspace_skills_layout_signature from oclaw.runtime.system_prompt import build_oclaw_executor_system_prompt from oclaw.runtime.types import OclawMemoryContext from oclaw.platform.persistence.sqlite_store import SqliteStore @@ -129,6 +130,79 @@ def test_executor_static_prompt_cache_invalidates_on_settings_change(monkeypatch assert calls["n"] == 2 +def test_workspace_skills_layout_signature_changes_on_skill_md_edit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + root = tmp_path / "skills_root_sig" + (root / "pkg").mkdir(parents=True) + f = root / "pkg" / "SKILL.md" + f.write_text("---\nname: x\ndescription: d\n---\n", encoding="utf-8") + monkeypatch.setenv("AIA_SKILLS_ROOT", str(root)) + a = workspace_skills_layout_signature() + f.write_text("---\nname: x\ndescription: d\n---\n\nbody", encoding="utf-8") + b = workspace_skills_layout_signature() + assert a != b + + +def test_executor_static_prompt_cache_invalidates_on_workspace_skills_layout_change(monkeypatch: pytest.MonkeyPatch) -> None: + from oclaw.runtime import system_prompt as sp + + class DummyStore: + def get_setting(self, key: str) -> str: + _ = key + return "" + + class DummyReg: + pass + + calls = {"n": 0} + sig = {"v": "layout-a"} + + def _skills_block(**kwargs) -> str: + _ = kwargs + calls["n"] += 1 + return "skills-block" + + monkeypatch.setattr(sp, "expert_workspace_signature_token", lambda: ("sig",)) + monkeypatch.setattr(sp, "workspace_skills_layout_signature", lambda: sig["v"]) + monkeypatch.setattr(sp, "build_project_context_block", lambda **kwargs: "") + monkeypatch.setattr(sp, "build_skills_catalog_block", _skills_block) + monkeypatch.setattr( + sp, + "render_runtime_prompt", + lambda prompt_id, variables, strict: f"{prompt_id}\n{variables.get('skills_catalog') or ''}", + ) + + store = DummyStore() + reg = DummyReg() + _ = sp.get_executor_prompt_static( + store=store, + tools=reg, # type: ignore[arg-type] + base_url="", + base_system="base", + workspace_dir=None, + skill_binding_role="generalist", + ) + _ = sp.get_executor_prompt_static( + store=store, + tools=reg, # type: ignore[arg-type] + base_url="", + base_system="base", + workspace_dir=None, + skill_binding_role="generalist", + ) + assert calls["n"] == 1 + + sig["v"] = "layout-b" + _ = sp.get_executor_prompt_static( + store=store, + tools=reg, # type: ignore[arg-type] + base_url="", + base_system="base", + workspace_dir=None, + skill_binding_role="generalist", + ) + assert calls["n"] == 2 + + def test_executor_static_prompt_cache_invalidates_on_skill_role_binding_change(monkeypatch: pytest.MonkeyPatch) -> None: from oclaw.runtime import system_prompt as sp