Reattach loaded skills after compaction

When compression hides an explicitly loaded skill body, reattach the current missing revision as a normal skills_tool history result under one fixed budget. Preserve skill name and revision metadata in automatic and manual compaction summaries without copying full skill bodies.

Alessandro committed Jun 16, 2026 at 14:31 UTC 3c83b2eca290f824867cb51cbed3fec60de2a8cb
12 files changed +222 -25
extensions/python/message_loop_prompts_after/AGENTS.md
+3 -2
@@ -2,11 +2,11 @@
2
3 ## Purpose
4
5 -- Own prompt extras appended after primary message-loop prompt construction.
5 +- Own prompt extras and history-output adjustments appended after primary message-loop prompt construction.
6
7 ## Ownership
8
9 -- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
9 +- Ordered Python files own current datetime, skill recall/load context, loaded-skill reattachment, agent info, parallel job status, and workdir extras injection.
10
11 ## Local Contracts
12
@@ -14,6 +14,7 @@
14 - Preserve ordering where later prompt extras depend on earlier recall or load results.
15 - Do not expose secrets or private files from workdir extras.
16 - Explicitly loaded skill instructions belong in normal tool-result history; this hook may recall candidate skills, but must not reinject loaded skill bodies through prompt extras every turn.
17 +- If compression hides an explicitly loaded skill body, this hook may reattach the missing visible revision as a bounded normal tool-result history message.
18
19 ## Work Guidance
20
extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
+73 -3
@@ -1,9 +1,15 @@
1 from helpers.extension import Extension
2 -from helpers import skills
2 +from helpers import skills, tokens
3 from tools.skills_tool import DATA_NAME_LOADED_SKILLS
4 from agent import LoopData
5
6
7 +SKILL_REATTACHMENT_TOKEN_BUDGET = 12_000
8 +SKILL_REATTACHMENT_HEADER = (
9 + "Reattached loaded skill instructions after history compaction."
10 +)
11 +
12 +
13 class IncludeLoadedSkills(Extension):
14 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
15 if not self.agent:
@@ -18,8 +24,72 @@ class IncludeLoadedSkills(Extension):
24 # tool-result history message. Keep this legacy ledger pruned, but do
25 # not reinject loaded skills through prompt extras every turn.
26 visible_skill_names = []
27 + loaded_skills = []
28 for skill_name in skill_names:
22 - if not skills.find_skill(skill_name, agent=self.agent):
29 + skill = skills.find_skill(skill_name, agent=self.agent)
30 + if not skill:
31 continue
24 - visible_skill_names.append(skill_name)
32 + visible_skill_names.append(skill.name)
33 + loaded_skills.append(skill)
34 self.agent.data[DATA_NAME_LOADED_SKILLS] = visible_skill_names
35 +
36 + self._reattach_missing_skill_bodies(loop_data, loaded_skills)
37 +
38 + def _reattach_missing_skill_bodies(self, loop_data: LoopData, loaded_skills):
39 + if not self.agent or not loaded_skills:
40 + return
41 +
42 + visible_revisions = _visible_skill_revisions(loop_data.history_output)
43 + selected = []
44 + used_tokens = 0
45 +
46 + for skill in reversed(loaded_skills):
47 + skill_data = skills.load_skill_for_agent(
48 + skill_name=skill.name,
49 + agent=self.agent,
50 + )
51 + revision = skills.skill_revision(skill_data)
52 + if (skill.name, revision) in visible_revisions:
53 + continue
54 +
55 + message = f"{SKILL_REATTACHMENT_HEADER}\n\n{skill_data}"
56 + message_tokens = tokens.approximate_tokens(message)
57 + if used_tokens + message_tokens > SKILL_REATTACHMENT_TOKEN_BUDGET:
58 + continue
59 +
60 + selected.append((skill, revision, message))
61 + used_tokens += message_tokens
62 +
63 + for skill, revision, message in reversed(selected):
64 + history_message = self.agent.hist_add_tool_result(
65 + "skills_tool",
66 + message,
67 + skill_instructions={
68 + "name": skill.name,
69 + "path": str(skill.path),
70 + "revision": revision,
71 + "source": "skills_tool:reattach",
72 + "content_included": True,
73 + },
74 + )
75 + loop_data.history_output.extend(history_message.output())
76 +
77 +
78 +def _visible_skill_revisions(history_output) -> set[tuple[str, str]]:
79 + visible = set()
80 + for message in history_output or []:
81 + if not isinstance(message, dict):
82 + continue
83 + content = message.get("content")
84 + if not isinstance(content, dict):
85 + continue
86 + meta = content.get("skill_instructions")
87 + if not isinstance(meta, dict):
88 + continue
89 + if not meta.get("content_included"):
90 + continue
91 + name = str(meta.get("name") or "").strip()
92 + revision = str(meta.get("revision") or "").strip()
93 + if name and revision:
94 + visible.add((name, revision))
95 + return visible
helpers/skills.py
+5
@@ -1,5 +1,6 @@
1 from __future__ import annotations
2
3 +import hashlib
4 import os
5 import re
6 from dataclasses import dataclass, field
@@ -451,6 +452,10 @@ def load_skill_for_agent(
452 return "\n".join(lines)
453
454
455 +def skill_revision(skill_data: str) -> str:
456 + return hashlib.sha256(skill_data.encode("utf-8")).hexdigest()[:16]
457 +
458 +
459 def _get_skill_files(skill_dir: Path) -> str:
460 """Get file tree for skill directory."""
461 if not skill_dir.exists():
helpers/skills.py.dox.md
+3 -2
@@ -30,6 +30,7 @@
30 - `delete_skill(skill_path: str) -> None`: Delete a skill directory.
31 - `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> Optional[Skill]`
32 - `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context.
33 +- `skill_revision(skill_data: str) -> str`
34 - `_get_skill_files(skill_dir: Path) -> str`: Get file tree for skill directory.
35 - `search_skills(query: str, limit: int=..., agent: Agent | None=..., include_hidden: bool=...) -> List[Skill]`
36 - `validate_skill(skill: Skill) -> List[str]`
@@ -52,11 +53,11 @@
53 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
54 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
55 - Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, secret handling.
55 -- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
56 +- Imported dependency areas include: `__future__`, `dataclasses`, `hashlib`, `helpers`, `os`, `pathlib`, `re`, `typing`.
57
58 ## Key Concepts
59
59 -- Important called helpers/classes observed in the source: `dataclass`, `re.compile`, `field`, `Path`, `root.rglob`, `results.sort`, `re.sub`, `path.read_text`, `text.splitlines`, `join.strip`, `parse_frontmatter`, `frontmatter_text.splitlines`, `_parse_frontmatter_fallback`, `split_frontmatter`, `str.strip`, `_coerce_list`, `Skill`, `get_skill_roots`, `_filter_hidden_skills`, `files.get_abs_path`.
60 +- Important called helpers/classes observed in the source: `dataclass`, `re.compile`, `field`, `Path`, `root.rglob`, `results.sort`, `re.sub`, `path.read_text`, `text.splitlines`, `join.strip`, `parse_frontmatter`, `frontmatter_text.splitlines`, `_parse_frontmatter_fallback`, `split_frontmatter`, `str.strip`, `_coerce_list`, `Skill`, `get_skill_roots`, `_filter_hidden_skills`, `files.get_abs_path`, `hashlib.sha256`.
61 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
62
63 ## Work Guidance
plugins/_chat_compaction/AGENTS.md
+1
@@ -18,6 +18,7 @@
18 - Preserve chat history integrity and persistence after compaction.
19 - Keep generated summaries bounded by configured model and token limits.
20 - Do not discard original context data unless the compaction flow explicitly owns that behavior.
21 +- Preserve loaded skill name/revision metadata in summaries without copying full skill bodies.
22
23 ## Work Guidance
24
plugins/_chat_compaction/prompts/compact.sys.md
+1
@@ -6,6 +6,7 @@ Rules:
6 - Use terse bullet points, not prose
7 - Collapse related items into single lines
8 - Keep exact values: file paths, config values, code identifiers, credentials, URLs
9 +- Preserve loaded skill names and revisions from skill_instructions metadata, but do not copy full skill bodies
10 - Omit anything that can be re-derived from context
11 - Group by topic, not chronology
12 - No meta-commentary about the summarization
prompts/AGENTS.md
+1
@@ -18,6 +18,7 @@
18 - Keep placeholder names, include aliases, and template assumptions synchronized with prompt-loading code and extensions.
19 - Prompt changes can alter agent behavior; keep edits narrow and intentional.
20 - Maintain clear separation between core behavior prompts and profile/plugin-specific customization.
21 +- Framework summary prompts must preserve loaded skill name/revision metadata without copying full skill bodies.
22
23 ## Work Guidance
24
prompts/fw.bulk_summary.sys.md
+2 -1
@@ -7,7 +7,8 @@ You must return a single summary of all records
7 # Expected output
8 Your output will be a text of the summary
9 Length of the text should be one paragraph, approximately 100 words
10 +If a tool result includes skill_instructions metadata, preserve the loaded skill name and revision in the summary, but do not copy the full skill body
11 No intro
12 No conclusion
13 No formatting
13 -Only the summary text is returned
\ No newline at end of file
14 +Only the summary text is returned
prompts/fw.topic_summary.sys.md
+2 -1
@@ -8,7 +8,8 @@ You must return a single summary of all records
8 Your output will be a text of the summary
9 Summary must be shorter than original messages
10 Length of the text should be maximum one paragraph, approximately 100 words, shorter if original is shorter
11 +If a tool result includes skill_instructions metadata, preserve the loaded skill name and revision in the summary, but do not copy the full skill body
12 No intro
13 No conclusion
14 No formatting
14 -Only the summary text is returned
\ No newline at end of file
15 +Only the summary text is returned
tests/test_tool_action_contracts.py
+128 -8
@@ -78,6 +78,7 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
78 skills_stub.load_skill_for_agent = (
79 lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
80 )
81 + skills_stub.skill_revision = lambda skill_data: "rev1"
82 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
83
84 print_style_stub = types.ModuleType("helpers.print_style")
@@ -90,6 +91,65 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
91 return importlib.import_module("tools.skills_tool")
92
93
94 +class _FakeExtension:
95 + def __init__(self, agent=None):
96 + self.agent = agent
97 +
98 +
99 +class _FakeLoadedSkillAgent:
100 + def __init__(self) -> None:
101 + self.data = {"loaded_skills": ["browser-form-workflows"]}
102 + self.added_tool_results = []
103 +
104 + def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
105 + content = {"tool_name": tool_name, "tool_result": tool_result, **kwargs}
106 + self.added_tool_results.append(content)
107 + return types.SimpleNamespace(
108 + output=lambda: [{"ai": False, "content": content}]
109 + )
110 +
111 +
112 +def _load_loaded_skills_extension(monkeypatch, skill_root: Path):
113 + extension_stub = types.ModuleType("helpers.extension")
114 + extension_stub.Extension = _FakeExtension
115 + monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
116 +
117 + agent_stub = types.ModuleType("agent")
118 + agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs)
119 + monkeypatch.setitem(sys.modules, "agent", agent_stub)
120 +
121 + skills_stub = types.ModuleType("helpers.skills")
122 + fake_skill = _FakeSkill(
123 + name="browser-form-workflows",
124 + description="Use for complex browser forms.",
125 + path=skill_root,
126 + tags=[],
127 + )
128 + skills_stub.find_skill = lambda *args, **kwargs: fake_skill
129 + skills_stub.load_skill_for_agent = (
130 + lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
131 + )
132 + skills_stub.skill_revision = lambda skill_data: "rev1"
133 + monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
134 +
135 + tokens_stub = types.ModuleType("helpers.tokens")
136 + tokens_stub.approximate_tokens = lambda text: len(str(text).split())
137 + monkeypatch.setitem(sys.modules, "helpers.tokens", tokens_stub)
138 +
139 + import helpers
140 +
141 + monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
142 + monkeypatch.setattr(helpers, "tokens", tokens_stub, raising=False)
143 +
144 + skills_tool_stub = types.ModuleType("tools.skills_tool")
145 + skills_tool_stub.DATA_NAME_LOADED_SKILLS = "loaded_skills"
146 + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool_stub)
147 +
148 + module_name = "extensions.python.message_loop_prompts_after._65_include_loaded_skills"
149 + sys.modules.pop(module_name, None)
150 + return importlib.import_module(module_name)
151 +
152 +
153 def _load_computer_use_remote_tool(monkeypatch):
154 _install_tool_stub(monkeypatch)
155
@@ -235,15 +295,75 @@ def test_skills_tool_load_reloads_when_prior_skill_is_not_model_visible(
295 assert second.additional["skill_instructions"]["content_included"] is True
296
297
238 -def test_loaded_skills_extension_no_longer_reinjects_skill_bodies():
239 - project_root = Path(__file__).resolve().parents[1]
240 - extension = (
241 - project_root
242 - / "extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py"
243 - ).read_text(encoding="utf-8")
298 +def test_loaded_skills_extension_reattaches_missing_body_after_compaction(
299 + monkeypatch, tmp_path: Path
300 +):
301 + module = _load_loaded_skills_extension(monkeypatch, tmp_path)
302 + agent = _FakeLoadedSkillAgent()
303 + loop_data = types.SimpleNamespace(
304 + extras_persistent={"loaded_skills": "legacy"},
305 + history_output=[
306 + {
307 + "ai": False,
308 + "content": "Earlier history was summarized and no skill body is visible.",
309 + }
310 + ],
311 + )
312 +
313 + asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
314 +
315 + assert "loaded_skills" not in loop_data.extras_persistent
316 + assert len(agent.added_tool_results) == 1
317 + added = agent.added_tool_results[0]
318 + assert added["tool_name"] == "skills_tool"
319 + assert "Skill: browser-form-workflows" in added["tool_result"]
320 + assert added["skill_instructions"] == {
321 + "name": "browser-form-workflows",
322 + "path": str(tmp_path),
323 + "revision": "rev1",
324 + "source": "skills_tool:reattach",
325 + "content_included": True,
326 + }
327 + assert loop_data.history_output[-1]["content"] == added
328 +
329 +
330 +def test_loaded_skills_extension_does_not_reattach_visible_revision(
331 + monkeypatch, tmp_path: Path
332 +):
333 + module = _load_loaded_skills_extension(monkeypatch, tmp_path)
334 + agent = _FakeLoadedSkillAgent()
335 + loop_data = types.SimpleNamespace(
336 + extras_persistent={},
337 + history_output=[
338 + {
339 + "ai": False,
340 + "content": {
341 + "skill_instructions": {
342 + "name": "browser-form-workflows",
343 + "revision": "rev1",
344 + "content_included": True,
345 + }
346 + },
347 + }
348 + ],
349 + )
350 +
351 + asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
352 +
353 + assert agent.added_tool_results == []
354 +
355 +
356 +def test_loaded_skills_extension_keeps_reattachments_under_budget(
357 + monkeypatch, tmp_path: Path
358 +):
359 + module = _load_loaded_skills_extension(monkeypatch, tmp_path)
360 + monkeypatch.setattr(module, "SKILL_REATTACHMENT_TOKEN_BUDGET", 1)
361 + agent = _FakeLoadedSkillAgent()
362 + loop_data = types.SimpleNamespace(extras_persistent={}, history_output=[])
363 +
364 + asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
365
245 - assert 'extras["loaded_skills"]' not in extension
246 - assert "load_skill_for_agent" not in extension
366 + assert agent.added_tool_results == []
367
368
369 def test_skills_tool_read_file_action_reads_inside_skill_dir(
tools/skills_tool.py
+1 -6
@@ -1,6 +1,5 @@
1 from __future__ import annotations
2
3 -import hashlib
3 from pathlib import Path
4 from typing import List
5
@@ -214,7 +213,7 @@ class SkillsTool(Tool):
213 skill_name=skill.name,
214 agent=self.agent,
215 )
217 - revision = self._skill_revision(skill_data)
216 + revision = skills_helper.skill_revision(skill_data)
217 metadata = {
218 "name": skill.name,
219 "path": str(skill.path),
@@ -255,10 +254,6 @@ class SkillsTool(Tool):
254 additional={"skill_instructions": metadata},
255 )
256
258 - @staticmethod
259 - def _skill_revision(skill_data: str) -> str:
260 - return hashlib.sha256(skill_data.encode("utf-8")).hexdigest()[:16]
261 -
257 def _visible_skill_revision_loaded(self, skill_name: str, revision: str) -> bool:
258 history_obj = getattr(self.agent, "history", None)
259 output = getattr(history_obj, "output", None)
tools/skills_tool.py.dox.md
+2 -2
@@ -29,11 +29,11 @@
29 - `load` includes a `skill_instructions` metadata sidecar in the tool-result content with skill name, path, revision, source, and whether full content was included.
30 - Duplicate `load` calls may omit the full body only when the same skill revision is still present in model-visible `history.output()` content.
31 - Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence, chat history content.
32 -- Imported dependency areas include: `__future__`, `hashlib`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
32 +- Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
33
34 ## Key Concepts
35
36 -- Important called helpers/classes observed in the source: `str.strip.lower.replace`, `skill_name.strip`, `super.get_log_object`, `self._normalize_skill_name`, `self.get_log_object`, `skills_helper.list_skills`, `join`, `skills_helper.search_skills`, `skills_helper.find_skill`, `skills_helper.load_skill_for_agent`, `hashlib.sha256`, `skill.path.resolve`, `Path`, `resolved.read_text`, `skill_name.startswith`, `skill_name.endswith`, `self._current_action`, `self.agent.context.log.log`, `Response`, `strip`, `loaded.remove`, `target.is_absolute`.
36 +- Important called helpers/classes observed in the source: `str.strip.lower.replace`, `skill_name.strip`, `super.get_log_object`, `self._normalize_skill_name`, `self.get_log_object`, `skills_helper.list_skills`, `join`, `skills_helper.search_skills`, `skills_helper.find_skill`, `skills_helper.load_skill_for_agent`, `skills_helper.skill_revision`, `skill.path.resolve`, `Path`, `resolved.read_text`, `skill_name.startswith`, `skill_name.endswith`, `self._current_action`, `self.agent.context.log.log`, `Response`, `strip`, `loaded.remove`, `target.is_absolute`.
37 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
38
39 ## Work Guidance