refactor(skills): move applied-skill body out of the identity system message

Step 3 of the skill/persona split: an applied skill (including default skills)
is CAPABILITY context, so its body no longer sits in the identity system
message. It rides its own message (user role) after the identity block, with a
short intro naming the active skill. The <available-skills> discovery catalog
stays in the system message.

Two consequences:
- The cached identity prefix (persona BASE + ENV + POLICIES + catalogs) stays
  stable across skills(load): loading/clearing a skill changes only the
  trailing capability message, not the identity block.
- The task_agent base (_agent_system_messages) is snapshotted BEFORE the skill
  block, so a parent's applied skill no longer leaks into the sub-agent prefix
  (the sub-agent supplies its own persona identity and skill via _exec_task).

PRE-MERGE GATE: the design gates this on a model-adherence eval (this branch vs
main) verifying the model follows a skill as well from a context message as it
did from the system message (design section 7 Q1; ASSUMED-neutral, UNVERIFIED).
That eval is not runnable in-tree and MUST clear before this branch merges.
Mechanical structure is pinned by TestSkillContextPlacement.

Deferred follow-up: sub-agent (task_agent) skill-resource materialization, so
${TURNSTONE_SKILL_DIR} stays literal on that path (unchanged since step 1).

Test helpers (_sys_content) now read the full prompt prefix (identity + skill
context) so placement-agnostic assertions keep working.
This commit is contained in:
Patrick Buckley
2026-07-03 05:07:30 -07:00
parent c023272b16
commit b0ed67aa60
5 changed files with 98 additions and 21 deletions
+15 -9
View File
@@ -88,10 +88,9 @@ def _make_session(**kwargs):
def _sys_content(session: ChatSession) -> str:
"""Extract the system message content."""
msgs = [m for m in session.system_messages if m["role"] == "system"]
assert msgs
return msgs[0]["content"]
"""Full prompt prefix: identity system message + any skill context message."""
assert session.system_messages
return "\n".join(m["content"] for m in session.system_messages)
def _create_template(db, template_id, name, content, is_default=False, **kwargs):
@@ -187,17 +186,24 @@ class TestDefaultTemplates:
content = _sys_content(session)
assert "Not default." not in content
def test_templates_before_instructions(self, tmp_db):
def test_default_template_delivered_as_context_after_identity(self, tmp_db):
"""Default templates are CAPABILITY context (step 3): delivered in a
separate message after the identity system message (which carries user
instructions), not interleaved into the identity block."""
from turnstone.core.storage import get_storage
db = get_storage()
_create_template(db, "t1", "tpl", "TEMPLATE_CONTENT", is_default=True)
session = _make_session(instructions="USER_INSTRUCTIONS")
content = _sys_content(session)
tpl_pos = content.index("TEMPLATE_CONTENT")
instr_pos = content.index("USER_INSTRUCTIONS")
assert tpl_pos < instr_pos
msgs = session.system_messages
# Instructions live in the identity system message; the template body
# does NOT.
assert "USER_INSTRUCTIONS" in msgs[0]["content"]
assert "TEMPLATE_CONTENT" not in msgs[0]["content"]
# Default template content rides the trailing skill capability message.
assert msgs[-1]["role"] == "user"
assert "TEMPLATE_CONTENT" in msgs[-1]["content"]
# ---------------------------------------------------------------------------
+2 -3
View File
@@ -135,9 +135,8 @@ def _create_skill(db: Any, skill_id: str, name: str, content: str, **kw: Any) ->
def _sys_content(session: ChatSession) -> str:
msgs = [m for m in session.system_messages if m["role"] == "system"]
assert msgs
return msgs[0]["content"]
assert session.system_messages
return "\n".join(m["content"] for m in session.system_messages)
# ---------------------------------------------------------------------------
@@ -224,3 +224,51 @@ class TestSkillResourceEnvAliases:
assert "CLAUDE_SKILL_DIR" not in env
finally:
session.close()
class TestSkillContextPlacement:
"""Step 3: an applied skill's body rides its own capability context
message (user role), separate from the identity system message — so it
never occupies the cached identity prefix or reads as identity, and it
does not leak into the task_agent base."""
def test_skill_body_in_context_message_not_identity(self, tmp_db: str) -> None:
db = get_storage()
_create_skill(db, "s1", "place-skill", "PLACEMENT_MARKER body text")
session = make_session(skill="place-skill")
try:
msgs = session.system_messages
# Identity system message is first, role=system, and skill-free.
assert msgs[0]["role"] == "system"
assert "PLACEMENT_MARKER" not in msgs[0]["content"]
# Skill rides exactly one separate user-role capability message.
skill_msgs = [m for m in msgs if m["role"] == "user"]
assert len(skill_msgs) == 1
assert "PLACEMENT_MARKER" in skill_msgs[0]["content"]
# The intro names the active skill so the model knows what it is.
assert "place-skill" in skill_msgs[0]["content"]
finally:
session.close()
def test_no_skill_no_context_message(self, tmp_db: str) -> None:
# No applied skill and no defaults → only the identity system message.
session = make_session()
try:
assert all(m["role"] == "system" for m in session.system_messages)
finally:
session.close()
def test_agent_prefix_excludes_skill_context(self, tmp_db: str) -> None:
# task_agent base = the identity system block only; the parent's
# applied skill does NOT leak into the sub-agent prefix.
db = get_storage()
_create_skill(db, "s1", "leak-skill", "SHOULD_NOT_LEAK body")
session = make_session(skill="leak-skill")
try:
assert len(session._agent_system_messages) == 1
assert session._agent_system_messages[0]["role"] == "system"
assert "SHOULD_NOT_LEAK" not in session._agent_system_messages[0]["content"]
finally:
session.close()
+3 -4
View File
@@ -111,10 +111,9 @@ def _make_session(**kwargs):
def _sys_content(session: ChatSession) -> str:
"""Extract the system message content."""
msgs = [m for m in session.system_messages if m["role"] == "system"]
assert msgs
return msgs[0]["content"]
"""Full prompt prefix: identity system message + any skill context message."""
assert session.system_messages
return "\n".join(m["content"] for m in session.system_messages)
def _create_template(db, template_id, name, content, **kwargs):
+30 -5
View File
@@ -3519,13 +3519,29 @@ class ChatSession:
"to invoke the prompts listed above."
)
dev_parts.append("\n".join(lines))
# Applied-skill body is CAPABILITY context, not identity — build it
# into its own block, delivered below as a separate (user-role)
# message rather than concatenated into the identity system prefix.
# Keeping it off that prefix means skills(load) no longer busts the
# cached identity block, and the skill never reads as who-the-agent-is.
# PRE-MERGE GATE: the model-adherence eval this vs main (design §7 Q1)
# is NOT run in-tree — it must clear before this branch merges.
skill_context = ""
if self._skill_content:
tpl = self._skill_content
if len(tpl) > _MAX_SKILL_CONTENT:
log.warning("skill_content.truncated", length=len(tpl))
tpl = tpl[:_MAX_SKILL_CONTENT]
dev_parts.append("")
dev_parts.append(tpl)
if self._skill_name:
intro = (
f"The following is the guidance for your active skill "
f"'{self._skill_name}'. Apply it throughout this session."
)
else:
intro = (
"The following is your active skill guidance. Apply it throughout this session."
)
skill_parts = [intro, "", tpl]
if self._skill_resources:
lines = ["<skill-resources>"]
total_size = 0
@@ -3549,7 +3565,8 @@ class ChatSession:
"All files are under $SKILL_RESOURCES_DIR."
)
lines.append("</skill-resources>")
dev_parts.append("\n".join(lines))
skill_parts.append("\n".join(lines))
skill_context = "\n".join(skill_parts)
# Skill catalog: disclose search-activated skills so the model
# knows they exist (Agent Skills standard progressive disclosure).
try:
@@ -3626,10 +3643,18 @@ class ChatSession:
"Use memory(action='search') or memory(action='list') for more."
)
new_system_messages.append({"role": "system", "content": "\n".join(dev_parts)})
# Agent prefix: the identity system block only (snapshotted BEFORE the
# skill block below) — a task_agent supplies its own persona identity
# and any skill as capability (see _exec_task), so the parent's applied
# skill no longer leaks into the sub-agent base.
self._agent_system_messages = list(new_system_messages)
# Applied-skill body rides its own capability message, off the cached
# identity prefix (see skill_context above). PRE-MERGE GATE: the
# model-adherence eval this-vs-main (design §7 Q1) is not run in-tree.
if skill_context:
new_system_messages.append({"role": "user", "content": skill_context})
# Atomic swap — readers see either old or new, never partial
self.system_messages = new_system_messages
# Agent prefix: system + developer only (no memories)
self._agent_system_messages = list(new_system_messages)
def _full_messages(self) -> list[dict[str, Any]]:
"""System messages + conversation history as wire dicts.