188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""Idempotence law for transcript decoration passes: f(f(x)) == f(x).
|
|
|
|
Pins bug #90971 (fixed in PR #91350): ``apply_anthropic_cache_control`` was
|
|
non-idempotent on pre-decorated input — the negative-slice / tool-budget
|
|
interplay stacked a fifth ``cache_control`` marker onto messages a prior
|
|
pass had already decorated, reproducing Anthropic's ``cache_control can only
|
|
be specified up to 4 times`` HTTP 400 mid-conversation.
|
|
|
|
Behavior contracts pinned here (complementing, not duplicating,
|
|
tests/agent/test_prompt_caching.py::TestApplyIdempotency which covers exact
|
|
layout convergence and caller non-mutation):
|
|
|
|
1. IDEMPOTENCE — for every realistic transcript shape x, the serialized form
|
|
of f(f(x)) is byte-identical to f(x).
|
|
2. BUDGET — the total decoration count after any number of passes never
|
|
exceeds Anthropic's 4-breakpoint budget.
|
|
|
|
Shapes include the #90971 regression input (already-decorated transcripts)
|
|
plus dict-content and list-content messages, tool blocks, and mixed forms.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from agent.prompt_caching import (
|
|
_count_cache_markers,
|
|
apply_anthropic_cache_control,
|
|
)
|
|
|
|
MARKER = {"type": "ephemeral"}
|
|
BUDGET = 4 # Anthropic's hard cap on cache_control breakpoints
|
|
|
|
|
|
def _canon(messages):
|
|
"""Deterministic byte serialization for the byte-identity assertion."""
|
|
return json.dumps(messages, sort_keys=True, ensure_ascii=False).encode()
|
|
|
|
|
|
def _tool_block(i, decorated=False):
|
|
# Two text parts: single-part [{"type","text"}] lists are canonicalized
|
|
# to plain strings by the strip pass (by-design flattening of the shape
|
|
# decoration itself produces), so multi-part is the realistic list form
|
|
# that must round-trip byte-exactly.
|
|
part = {"type": "text", "text": f"tool result {i}"}
|
|
if decorated:
|
|
part["cache_control"] = dict(MARKER)
|
|
return {
|
|
"role": "tool",
|
|
"tool_call_id": f"call_{i}",
|
|
"content": [{"type": "text", "text": f"tool header {i}"}, part],
|
|
}
|
|
|
|
|
|
def _clean_transcript():
|
|
"""system + alternating user/assistant, plain string (dict) content."""
|
|
msgs = [{"role": "system", "content": "STATIC_PREFIX system rules and tools"}]
|
|
for i in range(6):
|
|
msgs.append({"role": "user", "content": f"user turn {i}"})
|
|
msgs.append({"role": "assistant", "content": f"assistant turn {i}"})
|
|
return msgs
|
|
|
|
|
|
def _list_content_transcript():
|
|
"""List-content parts on user turns + tool blocks interleaved."""
|
|
msgs = [{"role": "system", "content": "STATIC_PREFIX system rules"}]
|
|
for i in range(4):
|
|
msgs.append({
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": f"question {i}"},
|
|
{"type": "text", "text": "attachment context"},
|
|
],
|
|
})
|
|
msgs.append({
|
|
"role": "assistant",
|
|
"content": f"calling tool {i}",
|
|
"tool_calls": [{"id": f"call_{i}", "type": "function",
|
|
"function": {"name": "t", "arguments": "{}"}}],
|
|
})
|
|
msgs.append(_tool_block(i))
|
|
msgs.append({"role": "assistant", "content": f"answer {i}"})
|
|
return msgs
|
|
|
|
|
|
def _predecorated_transcript():
|
|
"""The #90971 regression shape: input a PRIOR pass already decorated.
|
|
|
|
Markers live both top-level and on content parts (both shapes the
|
|
decorator itself produces), including on a tool block near the tail
|
|
where the negative-slice budget math went wrong.
|
|
"""
|
|
msgs = [{"role": "system", "content": [
|
|
{"type": "text", "text": "STATIC_PREFIX early", "cache_control": dict(MARKER)},
|
|
{"type": "text", "text": "late suffix", "cache_control": dict(MARKER)},
|
|
]}]
|
|
for i in range(5):
|
|
msgs.append({"role": "user", "content": f"u{i}"})
|
|
msgs.append({"role": "assistant", "content": f"a{i}"})
|
|
# tail: pre-decorated user (top-level marker) + decorated tool block
|
|
msgs.append({"role": "user", "content": "recent question",
|
|
"cache_control": dict(MARKER)})
|
|
msgs.append({"role": "assistant", "content": "using tool",
|
|
"tool_calls": [{"id": "call_z", "type": "function",
|
|
"function": {"name": "t", "arguments": "{}"}}]})
|
|
msgs.append(_tool_block(99, decorated=True))
|
|
msgs.append({"role": "assistant", "content": "final answer"})
|
|
return msgs
|
|
|
|
|
|
def _mixed_partially_decorated():
|
|
"""Stale markers scattered mid-transcript (replayed history)."""
|
|
msgs = _clean_transcript()
|
|
msgs[3] = dict(msgs[3], cache_control=dict(MARKER))
|
|
msgs[7] = {"role": "user", "content": [
|
|
{"type": "text", "text": "mid turn", "cache_control": dict(MARKER)},
|
|
]}
|
|
return msgs
|
|
|
|
|
|
SHAPES = [
|
|
pytest.param(_clean_transcript, id="clean-dict-content"),
|
|
pytest.param(_list_content_transcript, id="list-content-with-tools"),
|
|
pytest.param(_predecorated_transcript, id="predecorated-90971-regression"),
|
|
pytest.param(_mixed_partially_decorated, id="mixed-stale-markers"),
|
|
]
|
|
|
|
VARIANTS = [
|
|
pytest.param({}, id="default"),
|
|
pytest.param({"static_system_prefix": "STATIC_PREFIX"}, id="split-system"),
|
|
pytest.param({"native_anthropic": True}, id="native-anthropic"),
|
|
pytest.param({"cache_ttl": "1h",
|
|
"static_system_prefix": "STATIC_PREFIX"}, id="1h-ttl"),
|
|
]
|
|
|
|
|
|
class TestDecorationIdempotenceLaw:
|
|
"""f(f(x)) == f(x), byte-identical, for all transcript shapes (#90971)."""
|
|
|
|
@pytest.mark.parametrize("kwargs", VARIANTS)
|
|
@pytest.mark.parametrize("make", SHAPES)
|
|
def test_double_apply_is_byte_identical(self, make, kwargs):
|
|
once = apply_anthropic_cache_control(make(), **kwargs)
|
|
twice = apply_anthropic_cache_control(
|
|
copy.deepcopy(once), **kwargs)
|
|
assert _canon(twice) == _canon(once), (
|
|
"decoration pass is not idempotent: f(f(x)) != f(x)"
|
|
)
|
|
|
|
@pytest.mark.parametrize("kwargs", VARIANTS)
|
|
@pytest.mark.parametrize("make", SHAPES)
|
|
def test_budget_never_exceeded_across_passes(self, make, kwargs):
|
|
"""No pass — first, second, or fifth — may exceed Anthropic's
|
|
4-breakpoint budget, even when the input was already decorated."""
|
|
msgs = make()
|
|
for round_no in range(1, 6):
|
|
msgs = apply_anthropic_cache_control(msgs, **kwargs)
|
|
count = _count_cache_markers(msgs, [])
|
|
assert count <= BUDGET, (
|
|
f"round {round_no}: {count} cache_control markers exceed the "
|
|
f"{BUDGET}-breakpoint budget (the #90971 HTTP-400 class)"
|
|
)
|
|
|
|
def test_predecorated_input_does_not_inherit_extra_markers(self):
|
|
"""#90971 core symptom: pre-decorated input must not carry stale
|
|
markers PLUS fresh ones. Every marker in the output is one the
|
|
current pass placed — total exactly within budget, and no message
|
|
carries more than one marker at both levels."""
|
|
out = apply_anthropic_cache_control(
|
|
_predecorated_transcript(), static_system_prefix="STATIC_PREFIX")
|
|
assert _count_cache_markers(out, []) <= BUDGET
|
|
for msg in out:
|
|
top = 1 if "cache_control" in msg else 0
|
|
content = msg.get("content")
|
|
parts = (
|
|
sum(1 for p in content
|
|
if isinstance(p, dict) and "cache_control" in p)
|
|
if isinstance(content, list) else 0
|
|
)
|
|
# A single message may legitimately hold two SYSTEM breakpoints
|
|
# (prefix + suffix in the split-system layout); non-system
|
|
# messages must never stack top-level + part markers.
|
|
if msg.get("role") != "system":
|
|
assert top + parts <= 1, (
|
|
f"stacked markers on one message: {msg!r}"
|
|
)
|