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