diff --git a/tests/test_prompt_templates_runtime.py b/tests/test_prompt_templates_runtime.py index a2bf41f9..ebd6158c 100644 --- a/tests/test_prompt_templates_runtime.py +++ b/tests/test_prompt_templates_runtime.py @@ -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"] # --------------------------------------------------------------------------- diff --git a/tests/test_skill_resource_materialization.py b/tests/test_skill_resource_materialization.py index d3e037cd..db34395f 100644 --- a/tests/test_skill_resource_materialization.py +++ b/tests/test_skill_resource_materialization.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tests/test_skill_substitution_unification.py b/tests/test_skill_substitution_unification.py index 1cd98c07..ac807b21 100644 --- a/tests/test_skill_substitution_unification.py +++ b/tests/test_skill_substitution_unification.py @@ -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() diff --git a/tests/test_skills.py b/tests/test_skills.py index ecc5bfe9..ecaf1331 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -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): diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 0ce68b96..53f948a6 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -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 = [""] total_size = 0 @@ -3549,7 +3565,8 @@ class ChatSession: "All files are under $SKILL_RESOURCES_DIR." ) lines.append("") - 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.