d762ed9b3c
Un-fences OPENAI_MODEL_EXECUTION_GUIDANCE from the gpt/codex/grok substring check and gives it its own injection gate, independent of tool_use_enforcement, controlled by config.yaml `agent.execution_guidance` (auto/true/false/list — same semantics as tool_use_enforcement). The "auto" list (EXECUTION_GUIDANCE_MODELS) now also covers deepseek, kimi, qwen, glm, minimax, mimo, and mistral. Composio agentic-eval traces showed Hermes+DeepSeek/Kimi failing where competitors passed: financial math done in prose, no read-back after external writes, malformed identifiers "repaired", completeness claimed despite count mismatches. The discipline block existed but those models never received it. The block is extended with compact clauses distilled from that analysis: - external-write read-back (tool-call success is not task success; internal file edits already confirmed by the tool are not re-verified) - count reconciliation (declared totals/has_more are hard assertions) - literal preservation (never normalize identifiers that fail a stated format; lookup success does not validate a malformed token) - retry-differently (empty/partial/suspiciously narrow results get a broader retry before concluding) - completion gated on verification (done = every named acceptance criterion verified, never a plausible subset) The todo tool description now encourages enumeration-as-checklist for "all N items" tasks and gates completed status on verified work, never intent. Guidance is chosen once at session start keyed on model name, so the system prompt stays byte-stable for the life of a conversation. Supersedes/absorbs prior contributor proposals: #20588, #35087, #41874 (MiMo), #53847 (GLM tool-calls-as-text stall). Co-authored-by: Mat-London <56627804+Mat-London@users.noreply.github.com> Co-authored-by: intelac <8803887+intelac@users.noreply.github.com> Co-authored-by: 6ylqq <51219463+6ylqq@users.noreply.github.com> Co-authored-by: tauros1983 <267660491+tauros1983@users.noreply.github.com>
487 lines
20 KiB
Python
487 lines
20 KiB
Python
"""Tests for agent/system_prompt.py — context-file cwd wiring."""
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
|
|
|
|
|
|
def _make_agent(**overrides):
|
|
base = dict(
|
|
load_soul_identity=False,
|
|
skip_context_files=False,
|
|
valid_tool_names=[],
|
|
_task_completion_guidance=False,
|
|
_tool_use_enforcement=False,
|
|
_environment_probe=False,
|
|
_kanban_worker_guidance="",
|
|
_memory_store=None,
|
|
_memory_manager=None,
|
|
model="",
|
|
provider="",
|
|
platform="",
|
|
pass_session_id=False,
|
|
session_id="",
|
|
)
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
def _captured_context_cwd(agent):
|
|
"""The cwd build_system_prompt_parts hands to build_context_files_prompt."""
|
|
captured = {}
|
|
|
|
def fake_context_files(
|
|
cwd=None, skip_soul=False, context_length=None,
|
|
allow_install_tree_fallback=False, home_override=None,
|
|
):
|
|
captured["cwd"] = cwd
|
|
return ""
|
|
|
|
with (
|
|
patch("run_agent.load_soul_md", return_value=""),
|
|
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
|
patch("run_agent.build_environment_hints", return_value=""),
|
|
patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
|
|
):
|
|
build_system_prompt_parts(agent)
|
|
return captured["cwd"]
|
|
|
|
|
|
class TestContextFileCwd:
|
|
def test_none_when_terminal_cwd_unset(self, monkeypatch):
|
|
# Unset → None, so discovery falls back to the launch dir inside
|
|
# build_context_files_prompt (the local-CLI #19242 contract).
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
assert _captured_context_cwd(_make_agent()) is None
|
|
|
|
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
assert _captured_context_cwd(_make_agent()) == tmp_path
|
|
|
|
|
|
def _stable_prompt(agent):
|
|
with (
|
|
patch("run_agent.load_soul_md", return_value=""),
|
|
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
|
patch("run_agent.build_environment_hints", return_value=""),
|
|
patch("run_agent.build_context_files_prompt", return_value=""),
|
|
):
|
|
return build_system_prompt_parts(agent)["stable"]
|
|
|
|
|
|
def _prompt_parts(agent):
|
|
with (
|
|
patch("run_agent.load_soul_md", return_value=""),
|
|
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
|
patch("run_agent.build_environment_hints", return_value=""),
|
|
patch("run_agent.build_context_files_prompt", return_value=""),
|
|
):
|
|
return build_system_prompt_parts(agent)
|
|
|
|
|
|
def _init_code_repo(path):
|
|
"""A git repo that actually holds code — the coding posture requires a source
|
|
file (or manifest), not a bare ``.git`` (a prose/notes repo stays general)."""
|
|
import subprocess
|
|
|
|
subprocess.run(["git", "-C", str(path), "init", "-q"], check=True)
|
|
(path / "main.py").write_text("print('hi')\n")
|
|
|
|
|
|
class TestCodingContextBlock:
|
|
def test_injected_when_active(self, monkeypatch, tmp_path):
|
|
_init_code_repo(tmp_path)
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
|
|
parts = _prompt_parts(agent)
|
|
assert "coding agent" in parts["stable"]
|
|
assert "Workspace" in parts["context"]
|
|
|
|
def test_absent_when_off(self, monkeypatch, tmp_path):
|
|
_init_code_repo(tmp_path)
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
|
|
# Drive the real path: force the resolved mode to "off" via config.
|
|
with patch("agent.coding_context._coding_mode", return_value="off"):
|
|
stable = _stable_prompt(agent)
|
|
assert "coding agent" not in stable
|
|
|
|
def test_absent_without_tools(self, monkeypatch, tmp_path):
|
|
_init_code_repo(tmp_path)
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
agent = _make_agent(valid_tool_names=[], platform="cli")
|
|
assert "coding agent" not in _stable_prompt(agent)
|
|
|
|
|
|
class TestExecutionGuidanceInjection:
|
|
"""Injection gate for OPENAI_MODEL_EXECUTION_GUIDANCE via
|
|
``agent.execution_guidance`` (auto/true/false/list).
|
|
|
|
Background — Composio agentic-eval traces (2026-08): the block was
|
|
historically fenced to gpt/codex/grok AND nested inside the
|
|
tool-use-enforcement branch, so DeepSeek/Kimi/Qwen-class models
|
|
received no execution discipline at all. The gate is now independent
|
|
of tool_use_enforcement and defaults to a broader family list.
|
|
"""
|
|
|
|
def _prompt(self, model, execution_guidance="auto", *,
|
|
tool_use_enforcement=False,
|
|
valid_tool_names=("terminal", "read_file")):
|
|
agent = _make_agent(
|
|
valid_tool_names=list(valid_tool_names),
|
|
model=model,
|
|
_tool_use_enforcement=tool_use_enforcement,
|
|
_execution_guidance=execution_guidance,
|
|
)
|
|
return _stable_prompt(agent)
|
|
|
|
def test_deepseek_gets_guidance_by_default(self):
|
|
stable = self._prompt("deepseek/deepseek-v4-pro")
|
|
assert "Execution discipline" in stable
|
|
assert "<external_state_verification>" in stable
|
|
|
|
def test_kimi_gets_guidance_by_default(self):
|
|
assert "Execution discipline" in self._prompt("moonshotai/kimi-k3")
|
|
|
|
def test_qwen_glm_minimax_mimo_mistral_get_guidance_by_default(self):
|
|
for model in ("qwen/qwen-3-max", "z-ai/glm-5.2",
|
|
"minimax/minimax-m2", "xiaomi/mimo-v2",
|
|
"mistralai/mistral-large-3"):
|
|
assert "Execution discipline" in self._prompt(model), model
|
|
|
|
def test_gpt_still_gets_guidance(self):
|
|
assert "Execution discipline" in self._prompt("openai/gpt-5.5")
|
|
|
|
def test_grok_still_gets_guidance(self):
|
|
assert "Execution discipline" in self._prompt("xai/grok-4")
|
|
|
|
def test_independent_of_tool_use_enforcement(self):
|
|
# The gate must not require tool-use enforcement to be on.
|
|
stable = self._prompt("deepseek/deepseek-v4-flash",
|
|
tool_use_enforcement=False)
|
|
assert "Execution discipline" in stable
|
|
assert "Tool-use enforcement" not in stable
|
|
|
|
def test_claude_does_not_get_guidance_by_default(self):
|
|
assert "Execution discipline" not in self._prompt(
|
|
"anthropic/claude-opus-4.8")
|
|
|
|
def test_gemini_does_not_get_guidance_by_default(self):
|
|
assert "Execution discipline" not in self._prompt(
|
|
"google/gemini-2.5-pro")
|
|
|
|
def test_config_false_suppresses(self):
|
|
assert "Execution discipline" not in self._prompt(
|
|
"openai/gpt-5.5", execution_guidance=False)
|
|
assert "Execution discipline" not in self._prompt(
|
|
"deepseek/deepseek-v4-pro", execution_guidance="off")
|
|
|
|
def test_config_true_forces_for_any_model(self):
|
|
assert "Execution discipline" in self._prompt(
|
|
"anthropic/claude-opus-4.8", execution_guidance=True)
|
|
|
|
def test_config_list_matches_substring(self):
|
|
stable = self._prompt("mycorp/custom-llm-7b",
|
|
execution_guidance=["custom-llm", "gpt"])
|
|
assert "Execution discipline" in stable
|
|
|
|
def test_config_list_non_match_suppresses(self):
|
|
assert "Execution discipline" not in self._prompt(
|
|
"openai/gpt-5.5", execution_guidance=["deepseek"])
|
|
|
|
def test_no_tools_no_guidance(self):
|
|
assert "Execution discipline" not in self._prompt(
|
|
"deepseek/deepseek-v4-pro", valid_tool_names=())
|
|
|
|
|
|
class TestNamedProfileHintIntegration:
|
|
"""The same defect through the REAL resolution chain (#72894).
|
|
|
|
``TestNamedProfileHint`` mocks ``get_hermes_home``,
|
|
``get_default_hermes_root`` and ``_resolve_active_profile_name``, so it
|
|
validates template rendering but not the relationship that causes the bug:
|
|
``_resolve_active_profile_name`` returns a named profile *only* when the
|
|
active home is already ``<root>/profiles/<name>``, which is exactly why
|
|
appending that suffix again doubled it. Drive it with a real
|
|
``HERMES_HOME`` and no resolver mocks.
|
|
"""
|
|
|
|
def test_real_hermes_home_under_profiles_renders_correct_paths(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
root = tmp_path / ".hermes"
|
|
profile_home = root / "profiles" / "coder"
|
|
profile_home.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
|
|
# Sanity-check the real chain before asserting on the prompt.
|
|
from agent.file_safety import _resolve_active_profile_name
|
|
from hermes_constants import get_default_hermes_root, get_hermes_home
|
|
|
|
assert _resolve_active_profile_name() == "coder"
|
|
assert get_hermes_home() == profile_home
|
|
assert get_default_hermes_root() == root
|
|
|
|
agent = _make_agent(valid_tool_names=["read_file"])
|
|
with patch("agent.coding_context._coding_mode", return_value="off"):
|
|
prompt = "\n\n".join(_prompt_parts(agent).values())
|
|
|
|
assert "Active Hermes profile: coder." in prompt
|
|
assert f"reads and writes {profile_home}/." in prompt
|
|
# The doubled form must not appear anywhere.
|
|
assert f"{profile_home}/profiles/coder" not in prompt
|
|
# Default-profile pointers belong at the root, not inside the profile.
|
|
assert f"The default profile's data lives at {root}/skills/" in prompt
|
|
assert f"{profile_home}/skills/" not in prompt
|
|
|
|
def test_real_default_home_renders_default_branch(self, tmp_path, monkeypatch):
|
|
"""HERMES_HOME at the root resolves to the default profile, unchanged."""
|
|
root = tmp_path / ".hermes"
|
|
root.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
|
|
from agent.file_safety import _resolve_active_profile_name
|
|
|
|
assert _resolve_active_profile_name() == "default"
|
|
|
|
agent = _make_agent(valid_tool_names=["read_file"])
|
|
with patch("agent.coding_context._coding_mode", return_value="off"):
|
|
prompt = "\n\n".join(_prompt_parts(agent).values())
|
|
|
|
assert "Active Hermes profile: default." in prompt
|
|
assert f"under {root}/profiles/<name>/." in prompt
|
|
|
|
|
|
def test_build_system_prompt_records_stable_prefix():
|
|
agent = _make_agent()
|
|
with (
|
|
patch("run_agent.load_soul_md", return_value=""),
|
|
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
|
patch("run_agent.build_environment_hints", return_value=""),
|
|
patch("run_agent.build_context_files_prompt", return_value="context"),
|
|
):
|
|
prompt = build_system_prompt(agent)
|
|
|
|
assert prompt.startswith(agent._cached_system_prompt_static)
|
|
assert prompt[len(agent._cached_system_prompt_static):].startswith("\n\ncontext")
|
|
|
|
|
|
def test_coding_prompt_preserves_legacy_workspace_order(monkeypatch):
|
|
"""The cache split must not reorder the stored coding prompt."""
|
|
import agent.system_prompt as system_prompt
|
|
|
|
agent = _make_agent(
|
|
valid_tool_names=["read_file"],
|
|
_parallel_tool_call_guidance=False,
|
|
)
|
|
monkeypatch.setattr(system_prompt, "DEFAULT_AGENT_IDENTITY", "IDENTITY")
|
|
monkeypatch.setattr(system_prompt, "HERMES_AGENT_HELP_GUIDANCE", "HELP")
|
|
monkeypatch.setattr(system_prompt, "STEER_CHANNEL_NOTE", "STEER")
|
|
monkeypatch.setattr(system_prompt, "get_hermes_home", lambda: Path("/hermes"))
|
|
|
|
expected_profile = (
|
|
"Active Hermes profile: default. Other profiles (if any) live "
|
|
"under /hermes/profiles/<name>/. Each profile has its own skills/, "
|
|
"plugins/, cron/, and memories/ that affect a different session than "
|
|
"this one. Do not modify another profile's skills/plugins/cron/memories "
|
|
"unless the user explicitly directs you to."
|
|
)
|
|
expected = "\n\n".join((
|
|
"IDENTITY",
|
|
"HELP",
|
|
"STEER",
|
|
"CODING_STABLE",
|
|
"WORKSPACE",
|
|
"Operator instructions (from config):\nOPERATOR",
|
|
expected_profile,
|
|
"SYSTEM_MESSAGE",
|
|
"CONTEXT_FILES",
|
|
"Conversation started: Friday, January 02, 2026",
|
|
))
|
|
|
|
with (
|
|
patch("run_agent.load_soul_md", return_value=""),
|
|
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
|
patch("run_agent.build_environment_hints", return_value=""),
|
|
patch("run_agent.build_context_files_prompt", return_value="CONTEXT_FILES"),
|
|
patch(
|
|
"agent.coding_context.coding_system_prompt_parts",
|
|
return_value=(
|
|
["CODING_STABLE"],
|
|
["WORKSPACE"],
|
|
["Operator instructions (from config):\nOPERATOR"],
|
|
),
|
|
),
|
|
patch("agent.file_safety._resolve_active_profile_name", return_value="default"),
|
|
patch("hermes_time.now", return_value=datetime(2026, 1, 2)),
|
|
):
|
|
prompt = build_system_prompt(agent, system_message="SYSTEM_MESSAGE")
|
|
|
|
assert prompt == expected
|
|
assert agent._cached_system_prompt_static == "\n\n".join(expected.split("\n\n")[:4])
|
|
|
|
|
|
class TestTelegramRichMessagesHint:
|
|
"""Verify that TELEGRAM_RICH_MESSAGES_HINT is conditionally included."""
|
|
|
|
def test_base_hint_without_rich_messages(self, monkeypatch):
|
|
"""When rich_messages is False, only the base hint is used."""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {
|
|
"gateway": {"platforms": {"telegram": {"extra": {"rich_messages": False}}}}
|
|
}
|
|
stable = _stable_prompt(agent)
|
|
assert "Standard Markdown is automatically converted" in stable
|
|
assert "lean into it" not in stable
|
|
assert "task lists" not in stable
|
|
|
|
def test_rich_hint_with_rich_messages_enabled(self, monkeypatch):
|
|
"""When rich_messages is True in gateway.platforms, the extension
|
|
is appended (the canonical/primary location)."""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {
|
|
"gateway": {"platforms": {"telegram": {"extra": {"rich_messages": True}}}}
|
|
}
|
|
stable = _stable_prompt(agent)
|
|
assert "lean into it" in stable
|
|
assert "task lists" in stable
|
|
assert "math/formulas" in stable
|
|
|
|
def test_rich_hint_from_top_level_platforms(self):
|
|
"""Top-level ``platforms.telegram.extra.rich_messages`` is merged
|
|
alongside gateway.platforms, so it works on its own."""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {
|
|
"platforms": {"telegram": {"extra": {"rich_messages": True}}}
|
|
}
|
|
stable = _stable_prompt(agent)
|
|
assert "lean into it" in stable
|
|
assert "task lists" in stable
|
|
|
|
def test_top_level_overrides_gateway_rich_messages(self):
|
|
"""Top-level ``platforms.telegram.extra`` wins over gateway.platforms
|
|
at the leaf, matching the adapter's merge precedence."""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {
|
|
"gateway": {"platforms": {"telegram": {"extra": {"rich_messages": False}}}},
|
|
"platforms": {"telegram": {"extra": {"rich_messages": True}}},
|
|
}
|
|
stable = _stable_prompt(agent)
|
|
assert "lean into it" in stable
|
|
|
|
def test_gateway_extra_other_keys_does_not_block_top_level_rich_messages(self):
|
|
"""When gateway.platforms.telegram.extra has other keys but not
|
|
rich_messages, the top-level rich_messages still activates."""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {
|
|
"gateway": {"platforms": {"telegram": {"extra": {"disable_link_previews": True}}}},
|
|
"platforms": {"telegram": {"extra": {"rich_messages": True}}},
|
|
}
|
|
stable = _stable_prompt(agent)
|
|
assert "lean into it" in stable
|
|
|
|
def test_base_hint_without_config(self, monkeypatch):
|
|
"""When config has no telegram section, only base hint is used."""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {}
|
|
stable = _stable_prompt(agent)
|
|
assert "Standard Markdown is automatically converted" in stable
|
|
assert "lean into it" not in stable
|
|
|
|
|
|
def test_gateway_rich_messages_integration_via_real_config(self, tmp_path, monkeypatch):
|
|
"""End-to-end through the real config-resolution chain: a config.yaml
|
|
under HERMES_HOME with ``gateway.platforms.telegram.extra.rich_messages``
|
|
must activate the rich hint. ``load_config_readonly`` is NOT mocked here,
|
|
so this guards against the exact path-mismatch bug this PR fixes.
|
|
"""
|
|
config_yaml = (
|
|
"gateway:\n"
|
|
" platforms:\n"
|
|
" telegram:\n"
|
|
" extra:\n"
|
|
" rich_messages: true\n"
|
|
)
|
|
home = tmp_path / "hermes_home"
|
|
home.mkdir()
|
|
(home / "config.yaml").write_text(config_yaml)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
# Point config resolution at the temp file without mocking the loader:
|
|
# mirror the pattern used in test_config_env_expansion.py.
|
|
from hermes_cli import config as _cfgmod
|
|
monkeypatch.setattr(_cfgmod, "get_config_path", lambda: home / "config.yaml")
|
|
|
|
agent = _make_agent(platform="telegram")
|
|
stable = _stable_prompt(agent)
|
|
assert "lean into it" in stable
|
|
assert "task lists" in stable
|
|
|
|
def test_malformed_extra_value_falls_back_to_base_hint(self, tmp_path, monkeypatch):
|
|
"""A truthy non-mapping ``extra`` must not crash prompt construction —
|
|
it should fail open to the base hint (Tek's fail-open concern).
|
|
"""
|
|
agent = _make_agent(platform="telegram")
|
|
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
|
|
mock_cfg.return_value = {
|
|
"gateway": {"platforms": {"telegram": {"extra": "not-a-map"}}}
|
|
}
|
|
stable = _stable_prompt(agent)
|
|
assert "Standard Markdown is automatically converted" in stable
|
|
assert "lean into it" not in stable
|
|
|
|
|
|
_SKILLS = "SKILLS_INDEX_SENTINEL"
|
|
_CONTEXT = "CONTEXT_FILES_SENTINEL"
|
|
|
|
|
|
def _build(builder, **overrides):
|
|
"""Run a build_* function with skills + context files present."""
|
|
agent = _make_agent(valid_tool_names=["skills_list"], **overrides)
|
|
with (
|
|
patch("run_agent.load_soul_md", return_value=""),
|
|
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
|
patch("run_agent.build_environment_hints", return_value=""),
|
|
patch("run_agent.build_context_files_prompt", return_value=_CONTEXT),
|
|
patch("run_agent.get_toolset_for_tool", return_value=None),
|
|
patch("run_agent.build_skills_system_prompt", return_value=_SKILLS),
|
|
):
|
|
return builder(agent)
|
|
|
|
|
|
class TestSkillsInVolatileBand:
|
|
"""The skills index is runtime-mutable, so it lives in the volatile band,
|
|
not the stable band, to keep the cached stable prefix reusable when a
|
|
rebuild picks up a skill change."""
|
|
|
|
def test_skills_not_in_stable_band(self):
|
|
parts = _build(build_system_prompt_parts)
|
|
assert _SKILLS not in parts["stable"]
|
|
|
|
def test_skills_lead_the_volatile_band(self):
|
|
parts = _build(build_system_prompt_parts)
|
|
assert parts["volatile"].startswith(_SKILLS)
|
|
|
|
def test_full_order_is_stable_context_then_skills(self):
|
|
# build_system_prompt joins stable + context + volatile, so the skills
|
|
# index renders after the context files and before the per-turn
|
|
# memory/timestamp tail.
|
|
full = _build(build_system_prompt)
|
|
assert full.index(_CONTEXT) < full.index(_SKILLS)
|
|
assert full.index(_SKILLS) < full.index("Conversation started:")
|