Bust executor prompt cache on workspace SKILL.md layout changes

Add workspace_skills_layout_signature (hash of paths + mtimes + sizes) and fold it into get_executor_prompt_static and manager prebuild cache keys. Add tests for signature drift and cache invalidation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-11 16:28:16 +08:00
parent c33ca303a2
commit 939a734e76
4 changed files with 112 additions and 0 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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 ""),