Persist loaded skill instructions in history
Move explicit skill loads out of per-turn prompt extras and into normal tool-result history, with revision metadata for duplicate detection. Duplicate load calls now omit the full body only when the same revision remains model-visible after history output assembly.
Alessandro committed
Jun 16, 2026 at 14:24 UTC
db01d7c1c873f59b656618bb23890bb518d4c0a3
6 files changed
+187
-27
extensions/python/message_loop_prompts_after/AGENTS.md
+1
@@ -13,6 +13,7 @@
13
- Keep injected content bounded and clearly attributed.
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
18
## Work Guidance
19
extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
+4
-17
@@ -9,30 +9,17 @@ class IncludeLoadedSkills(Extension):
9
if not self.agent:
10
return
11
12
- extras = loop_data.extras_persistent
13
-
14
- # Get loaded skills names
12
+ loop_data.extras_persistent.pop("loaded_skills", None)
13
skill_names = self.agent.data.get(DATA_NAME_LOADED_SKILLS)
14
if not skill_names:
15
return
16
19
- # load skill text here
20
- content = ""
17
+ # `skills_tool load` now appends full skill instructions as a normal
18
+ # tool-result history message. Keep this legacy ledger pruned, but do
19
+ # not reinject loaded skills through prompt extras every turn.
20
visible_skill_names = []
21
for skill_name in skill_names:
22
if not skills.find_skill(skill_name, agent=self.agent):
23
continue
24
visible_skill_names.append(skill_name)
26
- skill_data = skills.load_skill_for_agent(skill_name=skill_name, agent=self.agent)
27
- content += "\n\n" + skill_data
25
self.agent.data[DATA_NAME_LOADED_SKILLS] = visible_skill_names
29
- content = content.strip()
30
- if not content:
31
- return
32
-
33
-
34
- # Inject into extras
35
- extras["loaded_skills"] = self.agent.read_prompt(
36
- "agent.system.skills.loaded.md",
37
- skills=content,
38
- )
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
tests/test_tool_action_contracts.py
+100
@@ -38,6 +38,7 @@ class _FakeAgent:
38
def __init__(self) -> None:
39
self.data = {}
40
self.context = types.SimpleNamespace(id="ctx")
41
+ self.history = types.SimpleNamespace(output=lambda: [])
42
43
def read_prompt(self, _name: str, **kwargs) -> str:
44
return f"deleted {kwargs.get('memory_count', 0)}"
@@ -74,6 +75,9 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
75
skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
76
skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
77
skills_stub.find_skill = lambda *args, **kwargs: fake_skill
78
+ skills_stub.load_skill_for_agent = (
79
+ lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
80
+ )
81
monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
82
83
print_style_stub = types.ModuleType("helpers.print_style")
@@ -146,6 +150,102 @@ def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path
150
assert "browser-form-workflows" in response.message
151
152
153
+def test_skills_tool_load_appends_skill_instructions_as_tool_result(
154
+ monkeypatch, tmp_path: Path
155
+):
156
+ module = _load_skills_tool(monkeypatch, tmp_path)
157
+ agent = _FakeAgent()
158
+ tool = module.SkillsTool(
159
+ agent,
160
+ "skills_tool",
161
+ None,
162
+ {"action": "load", "skill_name": "browser-form-workflows"},
163
+ "",
164
+ None,
165
+ )
166
+
167
+ response = asyncio.run(tool.execute(**tool.args))
168
+
169
+ assert "Skill: browser-form-workflows" in response.message
170
+ assert response.additional["skill_instructions"]["name"] == "browser-form-workflows"
171
+ assert response.additional["skill_instructions"]["content_included"] is True
172
+ assert agent.data["loaded_skills"] == ["browser-form-workflows"]
173
+
174
+
175
+def test_skills_tool_load_omits_duplicate_visible_skill_revision(
176
+ monkeypatch, tmp_path: Path
177
+):
178
+ module = _load_skills_tool(monkeypatch, tmp_path)
179
+ agent = _FakeAgent()
180
+ tool = module.SkillsTool(
181
+ agent,
182
+ "skills_tool",
183
+ None,
184
+ {"action": "load", "skill_name": "browser-form-workflows"},
185
+ "",
186
+ None,
187
+ )
188
+ first = asyncio.run(tool.execute(**tool.args))
189
+ loaded_message = {
190
+ "ai": False,
191
+ "content": {"skill_instructions": first.additional["skill_instructions"]},
192
+ }
193
+ agent.history = types.SimpleNamespace(output=lambda: [loaded_message])
194
+
195
+ second = asyncio.run(tool.execute(**tool.args))
196
+
197
+ assert "already loaded in visible chat history" in second.message
198
+ assert "Instructions:\nUse labels before typing." not in second.message
199
+ assert second.additional["skill_instructions"]["content_included"] is False
200
+ assert second.additional["skill_instructions"]["already_loaded"] is True
201
+ assert agent.data["loaded_skills"] == ["browser-form-workflows"]
202
+
203
+
204
+def test_skills_tool_load_reloads_when_prior_skill_is_not_model_visible(
205
+ monkeypatch, tmp_path: Path
206
+):
207
+ module = _load_skills_tool(monkeypatch, tmp_path)
208
+ agent = _FakeAgent()
209
+ tool = module.SkillsTool(
210
+ agent,
211
+ "skills_tool",
212
+ None,
213
+ {"action": "load", "skill_name": "browser-form-workflows"},
214
+ "",
215
+ None,
216
+ )
217
+ first = asyncio.run(tool.execute(**tool.args))
218
+ hidden_message = types.SimpleNamespace(
219
+ summary="",
220
+ content={"skill_instructions": first.additional["skill_instructions"]},
221
+ )
222
+ agent.history = types.SimpleNamespace(
223
+ all_messages=lambda: [hidden_message],
224
+ output=lambda: [
225
+ {
226
+ "ai": False,
227
+ "content": "Earlier history was summarized and no skill body is visible.",
228
+ }
229
+ ],
230
+ )
231
+
232
+ second = asyncio.run(tool.execute(**tool.args))
233
+
234
+ assert "Skill: browser-form-workflows" in second.message
235
+ assert second.additional["skill_instructions"]["content_included"] is True
236
+
237
+
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")
244
+
245
+ assert 'extras["loaded_skills"]' not in extension
246
+ assert "load_skill_for_agent" not in extension
247
+
248
+
249
def test_skills_tool_read_file_action_reads_inside_skill_dir(
250
monkeypatch, tmp_path: Path
251
):
tools/skills_tool.py
+75
-6
@@ -1,5 +1,6 @@
1
from __future__ import annotations
2
3
+import hashlib
4
from pathlib import Path
5
from typing import List
6
@@ -111,7 +112,7 @@ class SkillsTool(Tool):
112
skill_name = self._normalize_skill_name(
113
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
114
)
114
- return Response(message=self._load(skill_name), break_loop=False)
115
+ return self._load(skill_name)
116
if action == "read_file":
117
skill_name = self._normalize_skill_name(
118
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
@@ -185,11 +186,14 @@ class SkillsTool(Tool):
186
)
187
return "\n".join(lines)
188
188
- def _load(self, skill_name: str) -> str:
189
+ def _load(self, skill_name: str) -> Response:
190
skill_name = self._normalize_skill_name(skill_name)
191
192
if not skill_name:
192
- return "Error: 'skill_name' is required for action=load."
193
+ return Response(
194
+ message="Error: 'skill_name' is required for action=load.",
195
+ break_loop=False,
196
+ )
197
198
# Verify skill exists
199
skill = skills_helper.find_skill(
@@ -198,9 +202,29 @@ class SkillsTool(Tool):
202
agent=self.agent,
203
)
204
if not skill:
201
- return f"Error: skill not found: {skill_name!r}. Try skills_tool action=list or action=search."
205
+ return Response(
206
+ message=(
207
+ f"Error: skill not found: {skill_name!r}. "
208
+ "Try skills_tool action=list or action=search."
209
+ ),
210
+ break_loop=False,
211
+ )
212
203
- # Store skill name for fresh loading each turn
213
+ skill_data = skills_helper.load_skill_for_agent(
214
+ skill_name=skill.name,
215
+ agent=self.agent,
216
+ )
217
+ revision = self._skill_revision(skill_data)
218
+ metadata = {
219
+ "name": skill.name,
220
+ "path": str(skill.path),
221
+ "revision": revision,
222
+ "source": "skills_tool:load",
223
+ "content_included": True,
224
+ }
225
+
226
+ # Keep the old ledger for UI/backwards compatibility. The skill body now
227
+ # lives in normal tool-result history, not in per-turn prompt extras.
228
if not self.agent.data.get(DATA_NAME_LOADED_SKILLS):
229
self.agent.data[DATA_NAME_LOADED_SKILLS] = []
230
loaded = self.agent.data[DATA_NAME_LOADED_SKILLS]
@@ -209,7 +233,52 @@ class SkillsTool(Tool):
233
loaded.append(skill.name)
234
self.agent.data[DATA_NAME_LOADED_SKILLS] = loaded[-max_loaded_skills():]
235
212
- return f"Loaded skill '{skill.name}' into EXTRAS."
236
+ if self._visible_skill_revision_loaded(skill.name, revision):
237
+ return Response(
238
+ message=(
239
+ f"Skill '{skill.name}' is already loaded in visible "
240
+ "chat history for this revision."
241
+ ),
242
+ break_loop=False,
243
+ additional={
244
+ "skill_instructions": {
245
+ **metadata,
246
+ "content_included": False,
247
+ "already_loaded": True,
248
+ }
249
+ },
250
+ )
251
+
252
+ return Response(
253
+ message=skill_data,
254
+ break_loop=False,
255
+ additional={"skill_instructions": metadata},
256
+ )
257
+
258
+ @staticmethod
259
+ def _skill_revision(skill_data: str) -> str:
260
+ return hashlib.sha256(skill_data.encode("utf-8")).hexdigest()[:16]
261
+
262
+ def _visible_skill_revision_loaded(self, skill_name: str, revision: str) -> bool:
263
+ history_obj = getattr(self.agent, "history", None)
264
+ output = getattr(history_obj, "output", None)
265
+ if not callable(output):
266
+ return False
267
+
268
+ for message in output():
269
+ if not isinstance(message, dict):
270
+ continue
271
+ content = message.get("content")
272
+ if not isinstance(content, dict):
273
+ continue
274
+ meta = content.get("skill_instructions")
275
+ if not isinstance(meta, dict):
276
+ continue
277
+ if not meta.get("content_included"):
278
+ continue
279
+ if meta.get("name") == skill_name and meta.get("revision") == revision:
280
+ return True
281
+ return False
282
283
def _read_file(self, skill_name: str, file_path: str) -> str:
284
if not skill_name:
tools/skills_tool.py.dox.md
+6
-3
@@ -25,12 +25,15 @@
25
- Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
26
- `SkillsTool` is a `Tool`.
27
- `SkillsTool` defines `execute(...)`.
28
-- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence.
29
-- Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
28
+- `load` returns the full formatted skill instructions as the tool result so the instructions are appended once through normal message history instead of being reinjected through prompt extras.
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`.
33
34
## Key Concepts
35
33
-- 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`.
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`.
37
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
38
39
## Work Guidance