Revert loaded skill history persistence
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

Reverts db01d7c1c8 and 3c83b2eca2.

Restores the prior loaded-skills prompt-extras behavior and removes the compaction reattachment metadata path.
This commit is contained in:
Alessandro 2026-06-18 16:45:56 +02:00
parent 8fda0ee69c
commit bf2741990a
13 changed files with 32 additions and 389 deletions

View file

@ -2,19 +2,17 @@
## Purpose
- Own prompt extras and history-output adjustments appended after primary message-loop prompt construction.
- Own prompt extras appended after primary message-loop prompt construction.
## Ownership
- Ordered Python files own current datetime, skill recall/load context, loaded-skill reattachment, agent info, parallel job status, and workdir extras injection.
- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
## Local Contracts
- Keep injected content bounded and clearly attributed.
- Preserve ordering where later prompt extras depend on earlier recall or load results.
- Do not expose secrets or private files from workdir extras.
- 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.
- If compression hides an explicitly loaded skill body, this hook may reattach the missing visible revision as a bounded normal tool-result history message.
## Work Guidance

View file

@ -1,95 +1,38 @@
from helpers.extension import Extension
from helpers import skills, tokens
from helpers import skills
from tools.skills_tool import DATA_NAME_LOADED_SKILLS
from agent import LoopData
SKILL_REATTACHMENT_TOKEN_BUDGET = 12_000
SKILL_REATTACHMENT_HEADER = (
"Reattached loaded skill instructions after history compaction."
)
class IncludeLoadedSkills(Extension):
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
if not self.agent:
return
loop_data.extras_persistent.pop("loaded_skills", None)
extras = loop_data.extras_persistent
# Get loaded skills names
skill_names = self.agent.data.get(DATA_NAME_LOADED_SKILLS)
if not skill_names:
return
# `skills_tool load` now appends full skill instructions as a normal
# tool-result history message. Keep this legacy ledger pruned, but do
# not reinject loaded skills through prompt extras every turn.
# load skill text here
content = ""
visible_skill_names = []
loaded_skills = []
for skill_name in skill_names:
skill = skills.find_skill(skill_name, agent=self.agent)
if not skill:
if not skills.find_skill(skill_name, agent=self.agent):
continue
visible_skill_names.append(skill.name)
loaded_skills.append(skill)
visible_skill_names.append(skill_name)
skill_data = skills.load_skill_for_agent(skill_name=skill_name, agent=self.agent)
content += "\n\n" + skill_data
self.agent.data[DATA_NAME_LOADED_SKILLS] = visible_skill_names
self._reattach_missing_skill_bodies(loop_data, loaded_skills)
def _reattach_missing_skill_bodies(self, loop_data: LoopData, loaded_skills):
if not self.agent or not loaded_skills:
content = content.strip()
if not content:
return
visible_revisions = _visible_skill_revisions(loop_data.history_output)
selected = []
used_tokens = 0
for skill in reversed(loaded_skills):
skill_data = skills.load_skill_for_agent(
skill_name=skill.name,
agent=self.agent,
)
revision = skills.skill_revision(skill_data)
if (skill.name, revision) in visible_revisions:
continue
message = f"{SKILL_REATTACHMENT_HEADER}\n\n{skill_data}"
message_tokens = tokens.approximate_tokens(message)
if used_tokens + message_tokens > SKILL_REATTACHMENT_TOKEN_BUDGET:
continue
selected.append((skill, revision, message))
used_tokens += message_tokens
for skill, revision, message in reversed(selected):
history_message = self.agent.hist_add_tool_result(
"skills_tool",
message,
skill_instructions={
"name": skill.name,
"path": str(skill.path),
"revision": revision,
"source": "skills_tool:reattach",
"content_included": True,
},
)
loop_data.history_output.extend(history_message.output())
def _visible_skill_revisions(history_output) -> set[tuple[str, str]]:
visible = set()
for message in history_output or []:
if not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, dict):
continue
meta = content.get("skill_instructions")
if not isinstance(meta, dict):
continue
if not meta.get("content_included"):
continue
name = str(meta.get("name") or "").strip()
revision = str(meta.get("revision") or "").strip()
if name and revision:
visible.add((name, revision))
return visible
# Inject into extras
extras["loaded_skills"] = self.agent.read_prompt(
"agent.system.skills.loaded.md",
skills=content,
)

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass, field
@ -452,10 +451,6 @@ def load_skill_for_agent(
return "\n".join(lines)
def skill_revision(skill_data: str) -> str:
return hashlib.sha256(skill_data.encode("utf-8")).hexdigest()[:16]
def _get_skill_files(skill_dir: Path) -> str:
"""Get file tree for skill directory."""
if not skill_dir.exists():

View file

@ -30,7 +30,6 @@
- `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_revision(skill_data: str) -> 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]`
@ -53,11 +52,11 @@
- 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.
- Imported dependency areas include: `__future__`, `dataclasses`, `hashlib`, `helpers`, `os`, `pathlib`, `re`, `typing`.
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
## Key Concepts
- 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`.
- 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`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -18,7 +18,6 @@
- Preserve chat history integrity and persistence after compaction.
- Keep generated summaries bounded by configured model and token limits.
- Do not discard original context data unless the compaction flow explicitly owns that behavior.
- Preserve loaded skill name/revision metadata in summaries without copying full skill bodies.
## Work Guidance

View file

@ -6,7 +6,6 @@ Rules:
- Use terse bullet points, not prose
- Collapse related items into single lines
- Keep exact values: file paths, config values, code identifiers, credentials, URLs
- Preserve loaded skill names and revisions from skill_instructions metadata, but do not copy full skill bodies
- Omit anything that can be re-derived from context
- Group by topic, not chronology
- No meta-commentary about the summarization

View file

@ -18,7 +18,6 @@
- Keep placeholder names, include aliases, and template assumptions synchronized with prompt-loading code and extensions.
- Prompt changes can alter agent behavior; keep edits narrow and intentional.
- Maintain clear separation between core behavior prompts and profile/plugin-specific customization.
- Framework summary prompts must preserve loaded skill name/revision metadata without copying full skill bodies.
## Work Guidance

View file

@ -5,7 +5,7 @@ common args: action skill_name query file_path
workflow:
- action `search`: find candidate skills by keywords or trigger phrases from the current task
- action `list`: discover available skills
- action `load`: append one skill's full instructions to chat history by `skill_name`
- action `load`: load one skill by `skill_name`
- action `read_file`: open one file inside a loaded skill directory
if the user says "find/search a skill", call `search` before `load` even when the likely skill name seems obvious
`read_file` requires both `skill_name` and `file_path`; load the skill first, then read `SKILL.md` or the named relative file

View file

@ -7,8 +7,7 @@ You must return a single summary of all records
# Expected output
Your output will be a text of the summary
Length of the text should be one paragraph, approximately 100 words
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
No intro
No conclusion
No formatting
Only the summary text is returned
Only the summary text is returned

View file

@ -8,8 +8,7 @@ You must return a single summary of all records
Your output will be a text of the summary
Summary must be shorter than original messages
Length of the text should be maximum one paragraph, approximately 100 words, shorter if original is shorter
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
No intro
No conclusion
No formatting
Only the summary text is returned
Only the summary text is returned

View file

@ -38,7 +38,6 @@ class _FakeAgent:
def __init__(self) -> None:
self.data = {}
self.context = types.SimpleNamespace(id="ctx")
self.history = types.SimpleNamespace(output=lambda: [])
def read_prompt(self, _name: str, **kwargs) -> str:
return f"deleted {kwargs.get('memory_count', 0)}"
@ -75,10 +74,6 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
skills_stub.find_skill = lambda *args, **kwargs: fake_skill
skills_stub.load_skill_for_agent = (
lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
)
skills_stub.skill_revision = lambda skill_data: "rev1"
monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
print_style_stub = types.ModuleType("helpers.print_style")
@ -91,65 +86,6 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
return importlib.import_module("tools.skills_tool")
class _FakeExtension:
def __init__(self, agent=None):
self.agent = agent
class _FakeLoadedSkillAgent:
def __init__(self) -> None:
self.data = {"loaded_skills": ["browser-form-workflows"]}
self.added_tool_results = []
def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
content = {"tool_name": tool_name, "tool_result": tool_result, **kwargs}
self.added_tool_results.append(content)
return types.SimpleNamespace(
output=lambda: [{"ai": False, "content": content}]
)
def _load_loaded_skills_extension(monkeypatch, skill_root: Path):
extension_stub = types.ModuleType("helpers.extension")
extension_stub.Extension = _FakeExtension
monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
agent_stub = types.ModuleType("agent")
agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs)
monkeypatch.setitem(sys.modules, "agent", agent_stub)
skills_stub = types.ModuleType("helpers.skills")
fake_skill = _FakeSkill(
name="browser-form-workflows",
description="Use for complex browser forms.",
path=skill_root,
tags=[],
)
skills_stub.find_skill = lambda *args, **kwargs: fake_skill
skills_stub.load_skill_for_agent = (
lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
)
skills_stub.skill_revision = lambda skill_data: "rev1"
monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
tokens_stub = types.ModuleType("helpers.tokens")
tokens_stub.approximate_tokens = lambda text: len(str(text).split())
monkeypatch.setitem(sys.modules, "helpers.tokens", tokens_stub)
import helpers
monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
monkeypatch.setattr(helpers, "tokens", tokens_stub, raising=False)
skills_tool_stub = types.ModuleType("tools.skills_tool")
skills_tool_stub.DATA_NAME_LOADED_SKILLS = "loaded_skills"
monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool_stub)
module_name = "extensions.python.message_loop_prompts_after._65_include_loaded_skills"
sys.modules.pop(module_name, None)
return importlib.import_module(module_name)
def _load_computer_use_remote_tool(monkeypatch):
_install_tool_stub(monkeypatch)
@ -210,162 +146,6 @@ def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path
assert "browser-form-workflows" in response.message
def test_skills_tool_load_appends_skill_instructions_as_tool_result(
monkeypatch, tmp_path: Path
):
module = _load_skills_tool(monkeypatch, tmp_path)
agent = _FakeAgent()
tool = module.SkillsTool(
agent,
"skills_tool",
None,
{"action": "load", "skill_name": "browser-form-workflows"},
"",
None,
)
response = asyncio.run(tool.execute(**tool.args))
assert "Skill: browser-form-workflows" in response.message
assert response.additional["skill_instructions"]["name"] == "browser-form-workflows"
assert response.additional["skill_instructions"]["content_included"] is True
assert agent.data["loaded_skills"] == ["browser-form-workflows"]
def test_skills_tool_load_omits_duplicate_visible_skill_revision(
monkeypatch, tmp_path: Path
):
module = _load_skills_tool(monkeypatch, tmp_path)
agent = _FakeAgent()
tool = module.SkillsTool(
agent,
"skills_tool",
None,
{"action": "load", "skill_name": "browser-form-workflows"},
"",
None,
)
first = asyncio.run(tool.execute(**tool.args))
loaded_message = {
"ai": False,
"content": {"skill_instructions": first.additional["skill_instructions"]},
}
agent.history = types.SimpleNamespace(output=lambda: [loaded_message])
second = asyncio.run(tool.execute(**tool.args))
assert "already loaded in visible chat history" in second.message
assert "Instructions:\nUse labels before typing." not in second.message
assert second.additional["skill_instructions"]["content_included"] is False
assert second.additional["skill_instructions"]["already_loaded"] is True
assert agent.data["loaded_skills"] == ["browser-form-workflows"]
def test_skills_tool_load_reloads_when_prior_skill_is_not_model_visible(
monkeypatch, tmp_path: Path
):
module = _load_skills_tool(monkeypatch, tmp_path)
agent = _FakeAgent()
tool = module.SkillsTool(
agent,
"skills_tool",
None,
{"action": "load", "skill_name": "browser-form-workflows"},
"",
None,
)
first = asyncio.run(tool.execute(**tool.args))
hidden_message = types.SimpleNamespace(
summary="",
content={"skill_instructions": first.additional["skill_instructions"]},
)
agent.history = types.SimpleNamespace(
all_messages=lambda: [hidden_message],
output=lambda: [
{
"ai": False,
"content": "Earlier history was summarized and no skill body is visible.",
}
],
)
second = asyncio.run(tool.execute(**tool.args))
assert "Skill: browser-form-workflows" in second.message
assert second.additional["skill_instructions"]["content_included"] is True
def test_loaded_skills_extension_reattaches_missing_body_after_compaction(
monkeypatch, tmp_path: Path
):
module = _load_loaded_skills_extension(monkeypatch, tmp_path)
agent = _FakeLoadedSkillAgent()
loop_data = types.SimpleNamespace(
extras_persistent={"loaded_skills": "legacy"},
history_output=[
{
"ai": False,
"content": "Earlier history was summarized and no skill body is visible.",
}
],
)
asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
assert "loaded_skills" not in loop_data.extras_persistent
assert len(agent.added_tool_results) == 1
added = agent.added_tool_results[0]
assert added["tool_name"] == "skills_tool"
assert "Skill: browser-form-workflows" in added["tool_result"]
assert added["skill_instructions"] == {
"name": "browser-form-workflows",
"path": str(tmp_path),
"revision": "rev1",
"source": "skills_tool:reattach",
"content_included": True,
}
assert loop_data.history_output[-1]["content"] == added
def test_loaded_skills_extension_does_not_reattach_visible_revision(
monkeypatch, tmp_path: Path
):
module = _load_loaded_skills_extension(monkeypatch, tmp_path)
agent = _FakeLoadedSkillAgent()
loop_data = types.SimpleNamespace(
extras_persistent={},
history_output=[
{
"ai": False,
"content": {
"skill_instructions": {
"name": "browser-form-workflows",
"revision": "rev1",
"content_included": True,
}
},
}
],
)
asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
assert agent.added_tool_results == []
def test_loaded_skills_extension_keeps_reattachments_under_budget(
monkeypatch, tmp_path: Path
):
module = _load_loaded_skills_extension(monkeypatch, tmp_path)
monkeypatch.setattr(module, "SKILL_REATTACHMENT_TOKEN_BUDGET", 1)
agent = _FakeLoadedSkillAgent()
loop_data = types.SimpleNamespace(extras_persistent={}, history_output=[])
asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
assert agent.added_tool_results == []
def test_skills_tool_read_file_action_reads_inside_skill_dir(
monkeypatch, tmp_path: Path
):

View file

@ -111,7 +111,7 @@ class SkillsTool(Tool):
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
)
return self._load(skill_name)
return Response(message=self._load(skill_name), break_loop=False)
if action == "read_file":
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
@ -185,14 +185,11 @@ class SkillsTool(Tool):
)
return "\n".join(lines)
def _load(self, skill_name: str) -> Response:
def _load(self, skill_name: str) -> str:
skill_name = self._normalize_skill_name(skill_name)
if not skill_name:
return Response(
message="Error: 'skill_name' is required for action=load.",
break_loop=False,
)
return "Error: 'skill_name' is required for action=load."
# Verify skill exists
skill = skills_helper.find_skill(
@ -201,29 +198,9 @@ class SkillsTool(Tool):
agent=self.agent,
)
if not skill:
return Response(
message=(
f"Error: skill not found: {skill_name!r}. "
"Try skills_tool action=list or action=search."
),
break_loop=False,
)
return f"Error: skill not found: {skill_name!r}. Try skills_tool action=list or action=search."
skill_data = skills_helper.load_skill_for_agent(
skill_name=skill.name,
agent=self.agent,
)
revision = skills_helper.skill_revision(skill_data)
metadata = {
"name": skill.name,
"path": str(skill.path),
"revision": revision,
"source": "skills_tool:load",
"content_included": True,
}
# Keep the old ledger for UI/backwards compatibility. The skill body now
# lives in normal tool-result history, not in per-turn prompt extras.
# Store skill name for fresh loading each turn
if not self.agent.data.get(DATA_NAME_LOADED_SKILLS):
self.agent.data[DATA_NAME_LOADED_SKILLS] = []
loaded = self.agent.data[DATA_NAME_LOADED_SKILLS]
@ -232,48 +209,7 @@ class SkillsTool(Tool):
loaded.append(skill.name)
self.agent.data[DATA_NAME_LOADED_SKILLS] = loaded[-max_loaded_skills():]
if self._visible_skill_revision_loaded(skill.name, revision):
return Response(
message=(
f"Skill '{skill.name}' is already loaded in visible "
"chat history for this revision."
),
break_loop=False,
additional={
"skill_instructions": {
**metadata,
"content_included": False,
"already_loaded": True,
}
},
)
return Response(
message=skill_data,
break_loop=False,
additional={"skill_instructions": metadata},
)
def _visible_skill_revision_loaded(self, skill_name: str, revision: str) -> bool:
history_obj = getattr(self.agent, "history", None)
output = getattr(history_obj, "output", None)
if not callable(output):
return False
for message in output():
if not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, dict):
continue
meta = content.get("skill_instructions")
if not isinstance(meta, dict):
continue
if not meta.get("content_included"):
continue
if meta.get("name") == skill_name and meta.get("revision") == revision:
return True
return False
return f"Loaded skill '{skill.name}' into EXTRAS."
def _read_file(self, skill_name: str, file_path: str) -> str:
if not skill_name:

View file

@ -25,15 +25,12 @@
- Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
- `SkillsTool` is a `Tool`.
- `SkillsTool` defines `execute(...)`.
- `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.
- `load` includes a `skill_instructions` metadata sidecar in the tool-result content with skill name, path, revision, source, and whether full content was included.
- Duplicate `load` calls may omit the full body only when the same skill revision is still present in model-visible `history.output()` content.
- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence, chat history content.
- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence.
- Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
## Key Concepts
- 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`.
- 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`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance