Files
Auto-Trader/agent/tests/test_system_prompt_cache_stability.py
JackzigenandClaude 1aaef8dd33 test(context): address review — drop dead memory_summary kwarg, pin envelope ordering
- Remove stale memory_summary= kwarg from test_system_prompt_format_succeeds
  (str.format tolerates excess kwargs, so it silently passed while masking
  a potential reintroduction of the field).
- Pin that the <agent-state> envelope stays a sibling prefix outside
  <user-message> tags when loop.py goal-context wrapping is active.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-06 19:15:39 +08:00

131 lines
5.2 KiB
Python

"""System-prompt cache stability (issue #1707).
``WorkspaceMemory.to_summary()`` renders ``run_dir`` (a fresh
``timestamp_uuid`` directory per run) and tool ``counters`` (bumped on every
tool call). Rendering that summary into the system prompt meant the prompt
bytes changed on every turn and every tool call, invalidating the provider
prefix cache — including the ``cache_control`` block the Anthropic adapter
marks on the system message — for everything after the ``## State`` section.
The fix: the volatile summary rides the first **user** message wrapped in an
``<agent-state>`` envelope (same convention as ``<recalled-memories>``), and
the system template no longer has a ``## State`` section. The system prompt is
now byte-identical across runs as long as the registry, skills, and date
minute do not change.
"""
from __future__ import annotations
import pytest
from src.agent.context import ContextBuilder
from src.agent.memory import WorkspaceMemory
from src.agent.tools import ToolRegistry
pytestmark = pytest.mark.unit
def _builder(memory: WorkspaceMemory) -> ContextBuilder:
return ContextBuilder(ToolRegistry(), memory)
class TestSystemPromptCacheStability:
def test_prompt_is_byte_identical_when_run_dir_and_counters_change(self) -> None:
"""Different run_dir / counters must not change the system prompt."""
m1 = WorkspaceMemory()
m1.run_dir = "/runs/20260101_000000_abcdef"
m1.increment("get_market_data")
m2 = WorkspaceMemory()
m2.run_dir = "/runs/20260102_111111_fedcba"
m2.increment("get_market_data")
m2.increment("get_market_data")
m2.increment("backtest")
# Freeze the clock so only run_dir/counters differ between the two.
import src.agent.context as ctx
from datetime import datetime, timezone
frozen = datetime(2026, 10, 6, 12, 0, 0, tzinfo=timezone.utc)
orig = ctx.datetime
class _FrozenDatetime:
@staticmethod
def now(tz=None):
return frozen if tz is not None else frozen.replace(tzinfo=None)
try:
ctx.datetime = _FrozenDatetime # type: ignore[assignment]
p1 = _builder(m1).build_system_prompt("hello")
p2 = _builder(m2).build_system_prompt("hello")
finally:
ctx.datetime = orig
assert p1 == p2
def test_state_section_removed_from_system_template(self) -> None:
assert "## State" not in _builder(WorkspaceMemory()).build_system_prompt("x")
def test_volatile_state_rides_first_user_message(self) -> None:
m = WorkspaceMemory()
m.run_dir = "/runs/20260101_000000_abcdef"
m.increment("backtest")
messages = _builder(m).build_messages("analyze AAPL")
assert messages[0]["role"] == "system"
# The template's static Task Routing text may mention the literal
# "run_dir" parameter; what must NOT appear is the volatile value.
assert "/runs/20260101_000000_abcdef" not in messages[0]["content"]
assert "counters:" not in messages[0]["content"]
user = messages[-1]["content"]
assert user.startswith("<agent-state>")
assert "- run_dir: /runs/20260101_000000_abcdef" in user
assert "counters: backtest=1" in user
assert "analyze AAPL" in user
def test_empty_state_leaves_user_message_untouched(self) -> None:
messages = _builder(WorkspaceMemory()).build_messages("analyze AAPL")
assert messages[-1]["content"] == "analyze AAPL"
def test_state_envelope_sits_before_recalled_memories(self) -> None:
class _Recall:
title = "prefers conciseness"
memory_type = "preference"
body = "keep answers short"
class _PersistentMemory:
snapshot = None
def find_relevant(self, query: str, max_results: int = 3):
return [_Recall()]
m = WorkspaceMemory()
m.run_dir = "/runs/x"
builder = ContextBuilder(ToolRegistry(), m, persistent_memory=_PersistentMemory())
user = builder.build_messages("analyze AAPL")[-1]["content"]
# Both envelopes ride the same user message; order between them does
# not matter, but both must precede the raw user text.
assert "<agent-state>" in user
assert "<recalled-memories>" in user
assert user.index("<agent-state>") < user.index("analyze AAPL")
assert user.index("<recalled-memories>") < user.index("analyze AAPL")
def test_state_envelope_stays_outside_user_message_tags(self) -> None:
"""loop.py may wrap the raw text in <user-message> tags (goal context).
The state envelope must stay a sibling prefix, never nest inside —
it must not look like user-typed input."""
wrapped = "<goal-continuation>g</goal-continuation>\n\n<user-message>\nanalyze AAPL\n</user-message>"
m = WorkspaceMemory()
m.run_dir = "/runs/x"
user = _builder(m).build_messages(wrapped)[-1]["content"]
assert user.index("<agent-state>") < user.index("<user-message>")
closing = user.index("</user-message>")
opening = user.index("<user-message>")
assert "<agent-state>" not in user[opening:closing]