Persist loaded skills through compaction

Store explicitly loaded skill IDs as chat-wide context data while keeping full skill bodies in normal tool-result history. Reattach any loaded skill body that is no longer visible after compaction by reimporting the current skill from its source, without revision hashes or protocol reinjection.

Update compaction and summary prompts to preserve loaded skill names only, refresh DOX contracts, and add focused coverage for context-data persistence, legacy agent-data migration, duplicate suppression, and post-compaction reattachment.
This commit is contained in:
Alessandro 2026-06-23 16:32:21 +02:00
parent bd584da2f4
commit 53ad7ba2dc
14 changed files with 559 additions and 81 deletions

View file

@ -21,6 +21,7 @@ except Exception: # pragma: no cover
MAX_ACTIVE_SKILLS = 20
ACTIVE_SKILLS_PLUGIN_NAME = "_skills"
AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
CONTEXT_DATA_NAME_LOADED_SKILLS = AGENT_DATA_NAME_LOADED_SKILLS
CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS = "skills_chat_active"
CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS = "skills_chat_disabled"
CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS = "skills_chat_visible"
@ -451,6 +452,20 @@ def load_skill_for_agent(
return "\n".join(lines)
def skill_instruction_name(message: Any) -> str:
match message:
case {
"content": {
"skill_instructions": {
"content_included": included,
"name": name,
}
}
} if included:
return str(name or "").strip()
return ""
def _get_skill_files(skill_dir: Path) -> str:
"""Get file tree for skill directory."""
if not skill_dir.exists():
@ -830,19 +845,77 @@ def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
return _build_active_skills(agent, limit=get_max_active_skills(agent=agent))
def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]:
def _normalize_loaded_skill_names(raw: Any) -> list[str]:
if not isinstance(raw, list):
return []
names: list[str] = []
for value in raw:
name = str(value or "").strip()
if name and name not in names:
names.append(name)
return names
def get_loaded_skill_names(agent: Agent | None) -> list[str]:
if not agent:
return []
loaded = getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS)
if not isinstance(loaded, list):
return []
context = getattr(agent, "context", None)
if context and hasattr(context, "get_data"):
names = _normalize_loaded_skill_names(
context.get_data(CONTEXT_DATA_NAME_LOADED_SKILLS)
)
if names:
data = getattr(agent, "data", None)
if isinstance(data, dict):
data.pop(AGENT_DATA_NAME_LOADED_SKILLS, None)
return names
return [
{"name": str(skill_name).strip()}
for skill_name in loaded
if str(skill_name).strip()
]
legacy_names = _normalize_loaded_skill_names(
getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS)
)
if legacy_names:
set_loaded_skill_names(agent, legacy_names)
return legacy_names
def set_loaded_skill_names(agent: Agent | None, skill_names: Any) -> list[str]:
names = _normalize_loaded_skill_names(skill_names)[-MAX_ACTIVE_SKILLS:]
if not agent:
return names
context = getattr(agent, "context", None)
if context and hasattr(context, "set_data"):
context.set_data(CONTEXT_DATA_NAME_LOADED_SKILLS, names or None)
data = getattr(agent, "data", None)
if isinstance(data, dict):
data.pop(AGENT_DATA_NAME_LOADED_SKILLS, None)
return names
data = getattr(agent, "data", None)
if isinstance(data, dict):
data[AGENT_DATA_NAME_LOADED_SKILLS] = names
return names
def add_loaded_skill_name(
agent: Agent | None,
skill_name: str,
*,
limit: int | None = None,
) -> list[str]:
name = str(skill_name or "").strip()
if not name:
return get_loaded_skill_names(agent)
names = [loaded for loaded in get_loaded_skill_names(agent) if loaded != name]
names.append(name)
return set_loaded_skill_names(agent, names[-(limit or MAX_ACTIVE_SKILLS):])
def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]:
return [{"name": skill_name} for skill_name in get_loaded_skill_names(agent)]
def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
@ -850,17 +923,9 @@ def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
if not agent or not normalized:
return False
data = getattr(agent, "data", None)
if not isinstance(data, dict):
return False
loaded = data.get(AGENT_DATA_NAME_LOADED_SKILLS)
if not isinstance(loaded, list):
return False
next_loaded: list[str] = []
removed = False
for skill_name in loaded:
for skill_name in get_loaded_skill_names(agent):
loaded_entry = _normalize_active_skill_entry(str(skill_name))
if loaded_entry and _entries_match(loaded_entry, normalized):
removed = True
@ -868,7 +933,7 @@ def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
next_loaded.append(skill_name)
if removed:
data[AGENT_DATA_NAME_LOADED_SKILLS] = next_loaded
set_loaded_skill_names(agent, next_loaded)
return removed

View file

@ -30,6 +30,7 @@
- `delete_skill(skill_path: str) -> None`: Delete a skill directory.
- `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> Optional[Skill]`
- `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context.
- `skill_instruction_name(message: Any) -> str`
- `_get_skill_files(skill_dir: Path) -> str`: Get file tree for skill directory.
- `search_skills(query: str, limit: int=..., agent: Agent | None=..., include_hidden: bool=...) -> List[Skill]`
- `validate_skill(skill: Skill) -> List[str]`
@ -45,13 +46,17 @@
- `get_scope_hidden_skills(agent: Agent | None) -> list[ActiveSkillEntry]`
- `get_chat_active_skills(context: Any | None) -> list[ActiveSkillEntry]`
- `get_chat_disabled_skills(context: Any | None) -> list[ActiveSkillEntry]`
- Notable constants/configuration names: `MAX_ACTIVE_SKILLS`, `ACTIVE_SKILLS_PLUGIN_NAME`, `AGENT_DATA_NAME_LOADED_SKILLS`, `CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS`, `CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS`, `CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS`, `_NAME_RE`.
- `get_loaded_skill_names(agent: Agent | None) -> list[str]`
- `set_loaded_skill_names(agent: Agent | None, skill_names: Any) -> list[str]`
- `add_loaded_skill_name(agent: Agent | None, skill_name: str, limit: int | None=...) -> list[str]`
- Notable constants/configuration names: `MAX_ACTIVE_SKILLS`, `ACTIVE_SKILLS_PLUGIN_NAME`, `AGENT_DATA_NAME_LOADED_SKILLS`, `CONTEXT_DATA_NAME_LOADED_SKILLS`, `CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS`, `CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS`, `CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS`, `_NAME_RE`.
## Runtime Contracts
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, secret handling.
- Loaded skill names are chat-wide context data under `CONTEXT_DATA_NAME_LOADED_SKILLS`; legacy agent-local `loaded_skills` lists are migrated into context data and cleared when read.
- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
## Key Concepts