mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
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:
parent
c33ca303a2
commit
939a734e76
4 changed files with 112 additions and 0 deletions
|
|
@ -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.agents.specialists import discover_specialist_ids
|
||||||
from oclaw.runtime.direct_loop import tool_wire_freeze_status, warm_tool_wire_cache
|
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.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.system_prompt import get_executor_prompt_static, warm_executor_prompt_cache
|
||||||
from oclaw.runtime.tools.catalog import default_registry
|
from oclaw.runtime.tools.catalog import default_registry
|
||||||
from oclaw.runtime.workspaces.experts import expert_workspace_signature_token, list_experts
|
from oclaw.runtime.workspaces.experts import expert_workspace_signature_token, list_experts
|
||||||
|
|
@ -84,6 +85,7 @@ def get_manager_prompt_prebuild(
|
||||||
bool(memory_enabled),
|
bool(memory_enabled),
|
||||||
expert_workspace_signature_token(),
|
expert_workspace_signature_token(),
|
||||||
_manager_settings_signature(store),
|
_manager_settings_signature(store),
|
||||||
|
workspace_skills_layout_signature(),
|
||||||
)
|
)
|
||||||
with _MANAGER_PREBUILD_CACHE_LOCK:
|
with _MANAGER_PREBUILD_CACHE_LOCK:
|
||||||
cached = _MANAGER_PREBUILD_CACHE.get(cache_key)
|
cached = _MANAGER_PREBUILD_CACHE.get(cache_key)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -170,6 +171,38 @@ def discover_public_workspace_skill_names(skills_root: str | Path | None = None)
|
||||||
return out
|
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:
|
def _tool_origin(tool: "ToolSpec") -> str:
|
||||||
nm = str(getattr(tool, "name", "") or "")
|
nm = str(getattr(tool, "name", "") or "")
|
||||||
if nm.startswith("mcp__"):
|
if nm.startswith("mcp__"):
|
||||||
|
|
@ -344,6 +377,7 @@ __all__ = [
|
||||||
"build_skill_registry",
|
"build_skill_registry",
|
||||||
"default_skills_root",
|
"default_skills_root",
|
||||||
"discover_workspace_skill_manifests",
|
"discover_workspace_skill_manifests",
|
||||||
|
"workspace_skills_layout_signature",
|
||||||
"load_skill_manifest",
|
"load_skill_manifest",
|
||||||
"materialize_skills_from_tool_specs",
|
"materialize_skills_from_tool_specs",
|
||||||
"skill_runtime_diagnostics",
|
"skill_runtime_diagnostics",
|
||||||
|
|
|
||||||
|
|
@ -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.memory_stage import render_memory_context_block
|
||||||
from oclaw.runtime.project_context_prompt import build_project_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.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_prompt import build_skills_catalog_block
|
||||||
from oclaw.runtime.skills_workspace_lane import (
|
from oclaw.runtime.skills_workspace_lane import (
|
||||||
fs_safe_workspace_lane_segment,
|
fs_safe_workspace_lane_segment,
|
||||||
|
|
@ -205,6 +206,7 @@ def get_executor_prompt_static(
|
||||||
str(skill_binding_role or "").strip().lower(),
|
str(skill_binding_role or "").strip().lower(),
|
||||||
expert_workspace_signature_token(),
|
expert_workspace_signature_token(),
|
||||||
_executor_prompt_settings_signature(store),
|
_executor_prompt_settings_signature(store),
|
||||||
|
workspace_skills_layout_signature(),
|
||||||
bool(tools is not None),
|
bool(tools is not None),
|
||||||
int(bool(excl)),
|
int(bool(excl)),
|
||||||
str(lane_seg or ""),
|
str(lane_seg or ""),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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.system_prompt import build_oclaw_executor_system_prompt
|
||||||
from oclaw.runtime.types import OclawMemoryContext
|
from oclaw.runtime.types import OclawMemoryContext
|
||||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
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
|
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:
|
def test_executor_static_prompt_cache_invalidates_on_skill_role_binding_change(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
from oclaw.runtime import system_prompt as sp
|
from oclaw.runtime import system_prompt as sp
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue