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.

Alessandro committed Jun 23, 2026 at 16:32 UTC 53ad7ba2dc12edbb7de6873a0e1017cbbfda6df5
14 files changed +561 -83
extensions/python/message_loop_prompts_after/AGENTS.md
+5 -3
@@ -7,7 +7,9 @@
7 ## Ownership
8
9 - Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
10 -- Loaded and active skill instructions belong in prompt protocol, not prompt extras.
10 +- Active skill instructions belong in prompt protocol.
11 +- Explicitly loaded skill bodies belong in tool-result history with metadata so they can survive persistence and be reattached after compaction.
12 +- Explicitly loaded skill IDs are chat-wide context data, not agent-local state.
13
14 ## Local Contracts
15
@@ -17,11 +19,11 @@
19
20 ## Work Guidance
21
20 -- Coordinate prompt protocol and prompt-extra changes with skill, workdir, and profile contracts.
22 +- Coordinate prompt protocol, history-reattachment, and prompt-extra changes with skill, workdir, and profile contracts.
23
24 ## Verification
25
24 -- Inspect rendered prompt protocol/extras or run prompt-construction tests after changes.
26 +- Inspect rendered prompt protocol/history/extras or run prompt-construction tests after changes.
27
28 ## Child DOX Index
29
extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
+62 -20
@@ -1,39 +1,81 @@
1 from helpers.extension import Extension
2 -from helpers import skills
3 -from tools.skills_tool import DATA_NAME_LOADED_SKILLS
2 +from helpers import skills, tokens
3 from agent import LoopData
4
5
6 +SKILL_REATTACHMENT_TOKEN_BUDGET = 12_000
7 +SKILL_REATTACHMENT_HEADER = (
8 + "Reattached loaded skill instructions after history compaction."
9 +)
10 +
11 +
12 class IncludeLoadedSkills(Extension):
13 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
14 if not self.agent:
15 return
16
12 - protocol = loop_data.protocol_persistent
13 - protocol.pop("loaded_skills", None)
17 + loop_data.protocol_persistent.pop("loaded_skills", None)
18 + loop_data.extras_persistent.pop("loaded_skills", None)
19
15 - # Get loaded skills names
16 - skill_names = self.agent.data.get(DATA_NAME_LOADED_SKILLS)
20 + skill_names = skills.get_loaded_skill_names(self.agent)
21 if not skill_names:
22 return
23
20 - # load skill text here
21 - content = ""
24 + # Loaded skill bodies live in tool-result history. This hook only keeps
25 + # the ledger clean and restores bodies that compaction hid.
26 visible_skill_names = []
27 + loaded_skills = []
28 for skill_name in skill_names:
24 - 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
26 - visible_skill_names.append(skill_name)
27 - skill_data = skills.load_skill_for_agent(skill_name=skill_name, agent=self.agent)
28 - content += "\n\n" + skill_data
29 - self.agent.data[DATA_NAME_LOADED_SKILLS] = visible_skill_names
30 - content = content.strip()
31 - if not content:
32 + visible_skill_names.append(skill.name)
33 + loaded_skills.append(skill)
34 + skills.set_loaded_skill_names(self.agent, 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_skill_names = _visible_skill_names(loop_data.history_output)
43 + selected = []
44 + used_tokens = 0
45 +
46 + for skill in reversed(loaded_skills):
47 + if skill.name in visible_skill_names:
48 + continue
49 +
50 + skill_data = skills.load_skill_for_agent(
51 + skill_name=skill.name,
52 + agent=self.agent,
53 + )
54 + message = f"{SKILL_REATTACHMENT_HEADER}\n\n{skill_data}"
55 + message_tokens = tokens.approximate_tokens(message)
56 + if used_tokens + message_tokens > SKILL_REATTACHMENT_TOKEN_BUDGET:
57 + continue
58 +
59 + selected.append((skill, message))
60 + used_tokens += message_tokens
61 +
62 + for skill, message in reversed(selected):
63 + history_message = self.agent.hist_add_tool_result(
64 + "skills_tool",
65 + message,
66 + skill_instructions={
67 + "name": skill.name,
68 + "path": str(skill.path),
69 + "source": "skills_tool:reattach",
70 + "content_included": True,
71 + },
72 + )
73 + loop_data.history_output.extend(history_message.output())
74 +
75
35 - # Inject into protocol
36 - protocol["loaded_skills"] = self.agent.read_prompt(
37 - "agent.system.skills.loaded.md",
38 - skills=content,
39 - )
76 +def _visible_skill_names(history_output) -> set[str]:
77 + return {
78 + name
79 + for message in history_output or []
80 + if (name := skills.skill_instruction_name(message))
81 + }
helpers/skills.py
+84 -19
@@ -21,6 +21,7 @@ except Exception: # pragma: no cover
21 MAX_ACTIVE_SKILLS = 20
22 ACTIVE_SKILLS_PLUGIN_NAME = "_skills"
23 AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
24 +CONTEXT_DATA_NAME_LOADED_SKILLS = AGENT_DATA_NAME_LOADED_SKILLS
25 CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS = "skills_chat_active"
26 CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS = "skills_chat_disabled"
27 CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS = "skills_chat_visible"
@@ -451,6 +452,20 @@ def load_skill_for_agent(
452 return "\n".join(lines)
453
454
455 +def skill_instruction_name(message: Any) -> str:
456 + match message:
457 + case {
458 + "content": {
459 + "skill_instructions": {
460 + "content_included": included,
461 + "name": name,
462 + }
463 + }
464 + } if included:
465 + return str(name or "").strip()
466 + return ""
467 +
468 +
469 def _get_skill_files(skill_dir: Path) -> str:
470 """Get file tree for skill directory."""
471 if not skill_dir.exists():
@@ -830,37 +845,87 @@ def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
845 return _build_active_skills(agent, limit=get_max_active_skills(agent=agent))
846
847
833 -def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]:
834 - if not agent:
848 +def _normalize_loaded_skill_names(raw: Any) -> list[str]:
849 + if not isinstance(raw, list):
850 return []
851
837 - loaded = getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS)
838 - if not isinstance(loaded, list):
852 + names: list[str] = []
853 + for value in raw:
854 + name = str(value or "").strip()
855 + if name and name not in names:
856 + names.append(name)
857 + return names
858 +
859 +
860 +def get_loaded_skill_names(agent: Agent | None) -> list[str]:
861 + if not agent:
862 return []
863
841 - return [
842 - {"name": str(skill_name).strip()}
843 - for skill_name in loaded
844 - if str(skill_name).strip()
845 - ]
864 + context = getattr(agent, "context", None)
865 + if context and hasattr(context, "get_data"):
866 + names = _normalize_loaded_skill_names(
867 + context.get_data(CONTEXT_DATA_NAME_LOADED_SKILLS)
868 + )
869 + if names:
870 + data = getattr(agent, "data", None)
871 + if isinstance(data, dict):
872 + data.pop(AGENT_DATA_NAME_LOADED_SKILLS, None)
873 + return names
874 +
875 + legacy_names = _normalize_loaded_skill_names(
876 + getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS)
877 + )
878 + if legacy_names:
879 + set_loaded_skill_names(agent, legacy_names)
880 + return legacy_names
881
882
848 -def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
849 - normalized = _normalize_active_skill_entry(entry)
850 - if not agent or not normalized:
851 - return False
883 +def set_loaded_skill_names(agent: Agent | None, skill_names: Any) -> list[str]:
884 + names = _normalize_loaded_skill_names(skill_names)[-MAX_ACTIVE_SKILLS:]
885 + if not agent:
886 + return names
887 +
888 + context = getattr(agent, "context", None)
889 + if context and hasattr(context, "set_data"):
890 + context.set_data(CONTEXT_DATA_NAME_LOADED_SKILLS, names or None)
891 + data = getattr(agent, "data", None)
892 + if isinstance(data, dict):
893 + data.pop(AGENT_DATA_NAME_LOADED_SKILLS, None)
894 + return names
895
896 data = getattr(agent, "data", None)
854 - if not isinstance(data, dict):
855 - return False
897 + if isinstance(data, dict):
898 + data[AGENT_DATA_NAME_LOADED_SKILLS] = names
899 + return names
900
857 - loaded = data.get(AGENT_DATA_NAME_LOADED_SKILLS)
858 - if not isinstance(loaded, list):
901 +
902 +def add_loaded_skill_name(
903 + agent: Agent | None,
904 + skill_name: str,
905 + *,
906 + limit: int | None = None,
907 +) -> list[str]:
908 + name = str(skill_name or "").strip()
909 + if not name:
910 + return get_loaded_skill_names(agent)
911 +
912 + names = [loaded for loaded in get_loaded_skill_names(agent) if loaded != name]
913 + names.append(name)
914 + return set_loaded_skill_names(agent, names[-(limit or MAX_ACTIVE_SKILLS):])
915 +
916 +
917 +def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]:
918 + return [{"name": skill_name} for skill_name in get_loaded_skill_names(agent)]
919 +
920 +
921 +def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
922 + normalized = _normalize_active_skill_entry(entry)
923 + if not agent or not normalized:
924 return False
925
926 next_loaded: list[str] = []
927 removed = False
863 - for skill_name in loaded:
928 + for skill_name in get_loaded_skill_names(agent):
929 loaded_entry = _normalize_active_skill_entry(str(skill_name))
930 if loaded_entry and _entries_match(loaded_entry, normalized):
931 removed = True
@@ -868,7 +933,7 @@ def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
933 next_loaded.append(skill_name)
934
935 if removed:
871 - data[AGENT_DATA_NAME_LOADED_SKILLS] = next_loaded
936 + set_loaded_skill_names(agent, next_loaded)
937 return removed
938
939
helpers/skills.py.dox.md
+7 -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_instruction_name(message: Any) -> 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]`
@@ -45,13 +46,17 @@
46 - `get_scope_hidden_skills(agent: Agent | None) -> list[ActiveSkillEntry]`
47 - `get_chat_active_skills(context: Any | None) -> list[ActiveSkillEntry]`
48 - `get_chat_disabled_skills(context: Any | None) -> list[ActiveSkillEntry]`
48 -- 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`.
49 +- `get_loaded_skill_names(agent: Agent | None) -> list[str]`
50 +- `set_loaded_skill_names(agent: Agent | None, skill_names: Any) -> list[str]`
51 +- `add_loaded_skill_name(agent: Agent | None, skill_name: str, limit: int | None=...) -> list[str]`
52 +- 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`.
53
54 ## Runtime Contracts
55
56 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
57 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
54 -- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, secret handling.
58 +- 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.
59 +- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
60 - Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
61
62 ## Key Concepts
plugins/_chat_compaction/AGENTS.md
+1
@@ -18,6 +18,7 @@
18 - Preserve chat history integrity and persistence after compaction.
19 - Backup JSON and transcript artifacts must remain UTF-8 writable when chat content contains malformed Unicode such as lone surrogates.
20 - Keep generated summaries bounded by configured model and token limits.
21 +- Preserve loaded skill names from `skill_instructions` metadata without copying full skill bodies into compacted summaries.
22 - Do not discard original context data unless the compaction flow explicitly owns that behavior.
23
24 ## Work Guidance
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 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 +- Summary prompts that compress history should preserve loaded skill names from `skill_instructions` metadata without copying full skill bodies.
22
23 ## Work Guidance
24
prompts/agent.system.tool.skills.md
+1 -1
@@ -5,7 +5,7 @@ common args: action skill_name query file_path
5 workflow:
6 - action `search`: find candidate skills by keywords or trigger phrases from the current task
7 - action `list`: discover available skills
8 -- action `load`: load one skill by `skill_name`
8 +- action `load`: append one skill's full instructions to chat history by `skill_name`
9 - action `read_file`: open one file inside a loaded skill directory
10 if the user says "find/search a skill", call `search` before `load` even when the likely skill name seems obvious
11 `read_file` requires both `skill_name` and `file_path`; load the skill first, then read `SKILL.md` or the named relative file
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 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 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_skills_runtime.py
+50 -13
@@ -228,7 +228,24 @@ def test_reactivating_name_only_scope_default_by_path_clears_hidden_override(mon
228 assert runtime.get_chat_disabled_skills(agent.context) == []
229
230
231 -def test_loaded_skill_entries_come_from_agent_data():
231 +def test_loaded_skill_entries_come_from_context_data():
232 + agent = DummyAgent()
233 + agent.context.set_data(
234 + runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
235 + [
236 + "host-computer-use",
237 + "",
238 + "a0-development",
239 + ],
240 + )
241 +
242 + assert runtime.get_loaded_skill_entries(agent) == [
243 + {"name": "host-computer-use"},
244 + {"name": "a0-development"},
245 + ]
246 +
247 +
248 +def test_loaded_skill_entries_migrate_legacy_agent_data():
249 agent = DummyAgent()
250 agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
251 "host-computer-use",
@@ -240,6 +257,20 @@ def test_loaded_skill_entries_come_from_agent_data():
257 {"name": "host-computer-use"},
258 {"name": "a0-development"},
259 ]
260 + assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
261 + "host-computer-use",
262 + "a0-development",
263 + ]
264 + assert runtime.AGENT_DATA_NAME_LOADED_SKILLS not in agent.data
265 +
266 +
267 +def test_unloading_last_migrated_skill_does_not_restore_legacy_agent_data():
268 + agent = DummyAgent()
269 + agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = ["host-computer-use"]
270 +
271 + assert runtime.unload_agent_skill(agent, {"name": "host-computer-use"}) is True
272 + assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) is None
273 + assert runtime.get_loaded_skill_entries(agent) == []
274
275
276 def test_skill_runtime_does_not_alias_old_office_skill_references():
@@ -262,12 +293,15 @@ def test_skill_runtime_does_not_alias_old_office_skill_references():
293 ]
294
295 agent = DummyAgent()
265 - agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
266 - "office-artifacts",
267 - "word-documents",
268 - "excel-workbooks",
269 - "presentation-decks",
270 - ]
296 + agent.context.set_data(
297 + runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
298 + [
299 + "office-artifacts",
300 + "word-documents",
301 + "excel-workbooks",
302 + "presentation-decks",
303 + ],
304 + )
305
306 assert runtime.get_loaded_skill_entries(agent) == [
307 {"name": "office-artifacts"},
@@ -277,7 +311,7 @@ def test_skill_runtime_does_not_alias_old_office_skill_references():
311 ]
312
313 assert runtime.unload_agent_skill(agent, {"name": "office-artifacts"}) is True
280 - assert agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] == [
314 + assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
315 "word-documents",
316 "excel-workbooks",
317 "presentation-decks",
@@ -415,10 +449,13 @@ def test_host_computer_use_ranks_before_linux_desktop_for_host_screen_queries(mo
449
450 def test_unload_agent_skill_removes_loaded_skill_by_name():
451 agent = DummyAgent()
418 - agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
419 - "host-computer-use",
420 - "a0-development",
421 - ]
452 + agent.context.set_data(
453 + runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
454 + [
455 + "host-computer-use",
456 + "a0-development",
457 + ],
458 + )
459
460 removed = runtime.unload_agent_skill(
461 agent,
@@ -429,7 +466,7 @@ def test_unload_agent_skill_removes_loaded_skill_by_name():
466 )
467
468 assert removed is True
432 - assert agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] == [
469 + assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
470 "a0-development"
471 ]
472
tests/test_tool_action_contracts.py
+275 -4
@@ -34,10 +34,23 @@ class _FakeTool:
34 self.loop_data = loop_data
35
36
37 +class _FakeContext:
38 + def __init__(self, data: dict | None = None) -> None:
39 + self.id = "ctx"
40 + self.data = data or {}
41 +
42 + def get_data(self, key, recursive=True):
43 + return self.data.get(key)
44 +
45 + def set_data(self, key, value, recursive=True):
46 + self.data[key] = value
47 +
48 +
49 class _FakeAgent:
50 def __init__(self) -> None:
51 self.data = {}
40 - self.context = types.SimpleNamespace(id="ctx")
52 + self.context = _FakeContext()
53 + self.history = types.SimpleNamespace(output=lambda: [])
54
55 def read_prompt(self, _name: str, **kwargs) -> str:
56 return f"deleted {kwargs.get('memory_count', 0)}"
@@ -52,6 +65,50 @@ class _FakeSkill:
65 tags: list[str] | None = None
66
67
68 +def _normalize_loaded_skill_names(raw) -> list[str]:
69 + if not isinstance(raw, list):
70 + return []
71 + return [name for value in raw if (name := str(value or "").strip())]
72 +
73 +
74 +def _get_loaded_skill_names(agent) -> list[str]:
75 + names = _normalize_loaded_skill_names(agent.context.get_data("loaded_skills"))
76 + if names:
77 + return names
78 + names = _normalize_loaded_skill_names(agent.data.get("loaded_skills"))
79 + if names:
80 + _set_loaded_skill_names(agent, names)
81 + return names
82 +
83 +
84 +def _set_loaded_skill_names(agent, names) -> list[str]:
85 + names = _normalize_loaded_skill_names(names)
86 + agent.context.set_data("loaded_skills", names or None)
87 + return names
88 +
89 +
90 +def _add_loaded_skill_name(agent, skill_name, *, limit=None) -> list[str]:
91 + skill_name = str(skill_name or "").strip()
92 + names = [name for name in _get_loaded_skill_names(agent) if name != skill_name]
93 + if skill_name:
94 + names.append(skill_name)
95 + return _set_loaded_skill_names(agent, names[-(limit or 20):])
96 +
97 +
98 +def _skill_instruction_name(message) -> str:
99 + match message:
100 + case {
101 + "content": {
102 + "skill_instructions": {
103 + "content_included": included,
104 + "name": name,
105 + }
106 + }
107 + } if included:
108 + return str(name or "").strip()
109 + return ""
110 +
111 +
112 def _install_tool_stub(monkeypatch) -> None:
113 tool_stub = types.ModuleType("helpers.tool")
114 tool_stub.Tool = _FakeTool
@@ -64,6 +121,7 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
121
122 skills_stub = types.ModuleType("helpers.skills")
123 skills_stub.AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
124 + skills_stub.CONTEXT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
125 skills_stub.MAX_ACTIVE_SKILLS = 20
126 fake_skill = _FakeSkill(
127 name="browser-form-workflows",
@@ -74,6 +132,13 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
132 skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
133 skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
134 skills_stub.find_skill = lambda *args, **kwargs: fake_skill
135 + skills_stub.load_skill_for_agent = (
136 + lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
137 + )
138 + skills_stub.add_loaded_skill_name = _add_loaded_skill_name
139 + skills_stub.get_loaded_skill_names = _get_loaded_skill_names
140 + skills_stub.set_loaded_skill_names = _set_loaded_skill_names
141 + skills_stub.skill_instruction_name = _skill_instruction_name
142 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
143
144 print_style_stub = types.ModuleType("helpers.print_style")
@@ -86,6 +151,68 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
151 return importlib.import_module("tools.skills_tool")
152
153
154 +class _FakeExtension:
155 + def __init__(self, agent=None):
156 + self.agent = agent
157 +
158 +
159 +class _FakeLoadedSkillAgent:
160 + def __init__(self) -> None:
161 + self.data = {}
162 + self.context = _FakeContext({"loaded_skills": ["browser-form-workflows"]})
163 + self.added_tool_results = []
164 +
165 + def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
166 + content = {"tool_name": tool_name, "tool_result": tool_result, **kwargs}
167 + self.added_tool_results.append(content)
168 + return types.SimpleNamespace(
169 + output=lambda: [{"ai": False, "content": content}]
170 + )
171 +
172 +
173 +def _load_loaded_skills_extension(monkeypatch, skill_root: Path):
174 + extension_stub = types.ModuleType("helpers.extension")
175 + extension_stub.Extension = _FakeExtension
176 + monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
177 +
178 + agent_stub = types.ModuleType("agent")
179 + agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs)
180 + monkeypatch.setitem(sys.modules, "agent", agent_stub)
181 +
182 + skills_stub = types.ModuleType("helpers.skills")
183 + fake_skill = _FakeSkill(
184 + name="browser-form-workflows",
185 + description="Use for complex browser forms.",
186 + path=skill_root,
187 + tags=[],
188 + )
189 + skills_stub.find_skill = lambda *args, **kwargs: fake_skill
190 + skills_stub.load_skill_for_agent = (
191 + lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
192 + )
193 + skills_stub.get_loaded_skill_names = _get_loaded_skill_names
194 + skills_stub.set_loaded_skill_names = _set_loaded_skill_names
195 + skills_stub.skill_instruction_name = _skill_instruction_name
196 + monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
197 +
198 + tokens_stub = types.ModuleType("helpers.tokens")
199 + tokens_stub.approximate_tokens = lambda text: len(str(text).split())
200 + monkeypatch.setitem(sys.modules, "helpers.tokens", tokens_stub)
201 +
202 + import helpers
203 +
204 + monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
205 + monkeypatch.setattr(helpers, "tokens", tokens_stub, raising=False)
206 +
207 + skills_tool_stub = types.ModuleType("tools.skills_tool")
208 + skills_tool_stub.DATA_NAME_LOADED_SKILLS = "loaded_skills"
209 + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool_stub)
210 +
211 + module_name = "extensions.python.message_loop_prompts_after._65_include_loaded_skills"
212 + sys.modules.pop(module_name, None)
213 + return importlib.import_module(module_name)
214 +
215 +
216 def _load_computer_use_remote_tool(monkeypatch):
217 _install_tool_stub(monkeypatch)
218
@@ -146,7 +273,9 @@ def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path
273 assert "browser-form-workflows" in response.message
274
275
149 -def test_skills_tool_load_reports_protocol_injection(monkeypatch, tmp_path: Path):
276 +def test_skills_tool_load_appends_skill_instructions_as_tool_result(
277 + monkeypatch, tmp_path: Path
278 +):
279 module = _load_skills_tool(monkeypatch, tmp_path)
280 agent = _FakeAgent()
281 tool = module.SkillsTool(
@@ -160,8 +289,150 @@ def test_skills_tool_load_reports_protocol_injection(monkeypatch, tmp_path: Path
289
290 response = asyncio.run(tool.execute(**tool.args))
291
163 - assert response.message == "Loaded skill 'browser-form-workflows' into Protocol."
164 - assert agent.data["loaded_skills"] == ["browser-form-workflows"]
292 + assert "Skill: browser-form-workflows" in response.message
293 + assert response.additional["skill_instructions"]["name"] == "browser-form-workflows"
294 + assert response.additional["skill_instructions"]["content_included"] is True
295 + assert agent.context.get_data("loaded_skills") == ["browser-form-workflows"]
296 +
297 +
298 +def test_skills_tool_load_omits_duplicate_visible_skill(
299 + monkeypatch, tmp_path: Path
300 +):
301 + module = _load_skills_tool(monkeypatch, tmp_path)
302 + agent = _FakeAgent()
303 + tool = module.SkillsTool(
304 + agent,
305 + "skills_tool",
306 + None,
307 + {"action": "load", "skill_name": "browser-form-workflows"},
308 + "",
309 + None,
310 + )
311 + first = asyncio.run(tool.execute(**tool.args))
312 + loaded_message = {
313 + "ai": False,
314 + "content": {"skill_instructions": first.additional["skill_instructions"]},
315 + }
316 + agent.history = types.SimpleNamespace(output=lambda: [loaded_message])
317 +
318 + second = asyncio.run(tool.execute(**tool.args))
319 +
320 + assert "already loaded in visible chat history" in second.message
321 + assert "Instructions:\nUse labels before typing." not in second.message
322 + assert second.additional["skill_instructions"]["content_included"] is False
323 + assert second.additional["skill_instructions"]["already_loaded"] is True
324 + assert agent.context.get_data("loaded_skills") == ["browser-form-workflows"]
325 +
326 +
327 +def test_skills_tool_load_reloads_when_prior_skill_is_not_model_visible(
328 + monkeypatch, tmp_path: Path
329 +):
330 + module = _load_skills_tool(monkeypatch, tmp_path)
331 + agent = _FakeAgent()
332 + tool = module.SkillsTool(
333 + agent,
334 + "skills_tool",
335 + None,
336 + {"action": "load", "skill_name": "browser-form-workflows"},
337 + "",
338 + None,
339 + )
340 + first = asyncio.run(tool.execute(**tool.args))
341 + hidden_message = types.SimpleNamespace(
342 + summary="",
343 + content={"skill_instructions": first.additional["skill_instructions"]},
344 + )
345 + agent.history = types.SimpleNamespace(
346 + all_messages=lambda: [hidden_message],
347 + output=lambda: [
348 + {
349 + "ai": False,
350 + "content": "Earlier history was summarized and no skill body is visible.",
351 + }
352 + ],
353 + )
354 +
355 + second = asyncio.run(tool.execute(**tool.args))
356 +
357 + assert "Skill: browser-form-workflows" in second.message
358 + assert second.additional["skill_instructions"]["content_included"] is True
359 + assert agent.context.get_data("loaded_skills") == ["browser-form-workflows"]
360 +
361 +
362 +def test_loaded_skills_extension_reattaches_missing_body_after_compaction(
363 + monkeypatch, tmp_path: Path
364 +):
365 + module = _load_loaded_skills_extension(monkeypatch, tmp_path)
366 + agent = _FakeLoadedSkillAgent()
367 + loop_data = types.SimpleNamespace(
368 + protocol_persistent={"loaded_skills": "legacy"},
369 + extras_persistent={"loaded_skills": "legacy"},
370 + history_output=[
371 + {
372 + "ai": False,
373 + "content": "Earlier history was summarized and no skill body is visible.",
374 + }
375 + ],
376 + )
377 +
378 + asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
379 +
380 + assert "loaded_skills" not in loop_data.protocol_persistent
381 + assert "loaded_skills" not in loop_data.extras_persistent
382 + assert len(agent.added_tool_results) == 1
383 + added = agent.added_tool_results[0]
384 + assert added["tool_name"] == "skills_tool"
385 + assert "Skill: browser-form-workflows" in added["tool_result"]
386 + assert added["skill_instructions"] == {
387 + "name": "browser-form-workflows",
388 + "path": str(tmp_path),
389 + "source": "skills_tool:reattach",
390 + "content_included": True,
391 + }
392 + assert loop_data.history_output[-1]["content"] == added
393 +
394 +
395 +def test_loaded_skills_extension_does_not_reattach_visible_skill(
396 + monkeypatch, tmp_path: Path
397 +):
398 + module = _load_loaded_skills_extension(monkeypatch, tmp_path)
399 + agent = _FakeLoadedSkillAgent()
400 + loop_data = types.SimpleNamespace(
401 + protocol_persistent={},
402 + extras_persistent={},
403 + history_output=[
404 + {
405 + "ai": False,
406 + "content": {
407 + "skill_instructions": {
408 + "name": "browser-form-workflows",
409 + "content_included": True,
410 + }
411 + },
412 + }
413 + ],
414 + )
415 +
416 + asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
417 +
418 + assert agent.added_tool_results == []
419 +
420 +
421 +def test_loaded_skills_extension_keeps_reattachments_under_budget(
422 + monkeypatch, tmp_path: Path
423 +):
424 + module = _load_loaded_skills_extension(monkeypatch, tmp_path)
425 + monkeypatch.setattr(module, "SKILL_REATTACHMENT_TOKEN_BUDGET", 1)
426 + agent = _FakeLoadedSkillAgent()
427 + loop_data = types.SimpleNamespace(
428 + protocol_persistent={},
429 + extras_persistent={},
430 + history_output=[],
431 + )
432 +
433 + asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
434 +
435 + assert agent.added_tool_results == []
436
437
438 def test_skills_tool_read_file_action_reads_inside_skill_dir(
tools/skills_tool.py
+64 -16
@@ -8,7 +8,7 @@ from helpers import skills as skills_helper
8 from helpers.print_style import PrintStyle
9
10
11 -DATA_NAME_LOADED_SKILLS = skills_helper.AGENT_DATA_NAME_LOADED_SKILLS
11 +DATA_NAME_LOADED_SKILLS = skills_helper.CONTEXT_DATA_NAME_LOADED_SKILLS
12
13
14 class SkillsTool(Tool):
@@ -111,7 +111,7 @@ class SkillsTool(Tool):
111 skill_name = self._normalize_skill_name(
112 str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
113 )
114 - return Response(message=self._load(skill_name), break_loop=False)
114 + return self._load(skill_name)
115 if action == "read_file":
116 skill_name = self._normalize_skill_name(
117 str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
@@ -185,11 +185,14 @@ class SkillsTool(Tool):
185 )
186 return "\n".join(lines)
187
188 - def _load(self, skill_name: str) -> str:
188 + def _load(self, skill_name: str) -> Response:
189 skill_name = self._normalize_skill_name(skill_name)
190
191 if not skill_name:
192 - return "Error: 'skill_name' is required for action=load."
192 + return Response(
193 + message="Error: 'skill_name' is required for action=load.",
194 + break_loop=False,
195 + )
196
197 # Verify skill exists
198 skill = skills_helper.find_skill(
@@ -198,18 +201,63 @@ class SkillsTool(Tool):
201 agent=self.agent,
202 )
203 if not skill:
201 - return f"Error: skill not found: {skill_name!r}. Try skills_tool action=list or action=search."
202 -
203 - # Store skill name for fresh loading each turn
204 - if not self.agent.data.get(DATA_NAME_LOADED_SKILLS):
205 - self.agent.data[DATA_NAME_LOADED_SKILLS] = []
206 - loaded = self.agent.data[DATA_NAME_LOADED_SKILLS]
207 - if skill.name in loaded:
208 - loaded.remove(skill.name)
209 - loaded.append(skill.name)
210 - self.agent.data[DATA_NAME_LOADED_SKILLS] = loaded[-max_loaded_skills():]
211 -
212 - return f"Loaded skill '{skill.name}' into Protocol."
204 + return Response(
205 + message=(
206 + f"Error: skill not found: {skill_name!r}. "
207 + "Try skills_tool action=list or action=search."
208 + ),
209 + break_loop=False,
210 + )
211 +
212 + skill_data = skills_helper.load_skill_for_agent(
213 + skill_name=skill.name,
214 + agent=self.agent,
215 + )
216 + metadata = {
217 + "name": skill.name,
218 + "path": str(skill.path),
219 + "source": "skills_tool:load",
220 + "content_included": True,
221 + }
222 +
223 + skills_helper.add_loaded_skill_name(
224 + self.agent,
225 + skill.name,
226 + limit=max_loaded_skills(),
227 + )
228 +
229 + if self._visible_skill_loaded(skill.name):
230 + return Response(
231 + message=(
232 + f"Skill '{skill.name}' is already loaded in visible "
233 + "chat history."
234 + ),
235 + break_loop=False,
236 + additional={
237 + "skill_instructions": {
238 + **metadata,
239 + "content_included": False,
240 + "already_loaded": True,
241 + }
242 + },
243 + )
244 +
245 + return Response(
246 + message=skill_data,
247 + break_loop=False,
248 + additional={"skill_instructions": metadata},
249 + )
250 +
251 + def _visible_skill_loaded(self, skill_name: str) -> bool:
252 + history_obj = getattr(self.agent, "history", None)
253 + output = getattr(history_obj, "output", None)
254 + if not callable(output):
255 + return False
256 +
257 + return any(
258 + skills_helper.skill_instruction_name(message) == skill_name
259 + for message in output()
260 + )
261
262 def _read_file(self, skill_name: str, file_path: str) -> str:
263 if not skill_name:
tools/skills_tool.py.dox.md
+6 -3
@@ -15,6 +15,7 @@
15 - `get_log_object(self)`
16 - `async before_execution(self, **kwargs)`
17 - `async execute(self, **kwargs) -> Response`
18 + - `_visible_skill_loaded(self, skill_name: str) -> bool`
19 - Top-level functions:
20 - `max_loaded_skills() -> int`
21 - Notable constants/configuration names: `DATA_NAME_LOADED_SKILLS`.
@@ -25,13 +26,15 @@
26 - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
27 - `SkillsTool` is a `Tool`.
28 - `SkillsTool` defines `execute(...)`.
28 -- Loading a skill stores it for prompt Protocol injection on subsequent turns.
29 -- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence.
29 +- Loading a skill appends the full skill body as a normal tool-result history message with `skill_instructions` metadata containing name, path, source, and content visibility.
30 +- Loaded skill IDs are stored in chat-wide context data.
31 +- Duplicate loads omit the full body when the same skill name remains visible in model history.
32 +- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence, chat history persistence.
33 - Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
34
35 ## Key Concepts
36
34 -- 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`, `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 +- 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.add_loaded_skill_name`, `skills_helper.skill_instruction_name`, `skill.path.resolve`, `Path`, `resolved.read_text`, `skill_name.startswith`, `skill_name.endswith`, `self._current_action`, `self.agent.context.log.log`, `Response`, `strip`, `target.is_absolute`.
38 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
39
40 ## Work Guidance