1e638e1e3b
The summary ledger is regenerated every turn. At the head of history it invalidated the cached retained turns on every request once trimming started. Insert it immediately before the current turn's HumanMessage so system, tools and retained history stay byte-identical; excerpts of dropped turns stay at the head since they only change when the cut moves. Reserve a fixed ledger ceiling (SUMMARY_RESERVE_TOKENS) rather than its current size, so a shrinking ledger cannot pull the cut back and change the retained prefix; fall back to the actual size for tight budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
537 lines
19 KiB
Python
537 lines
19 KiB
Python
"""Regression tests for complete-turn context budgeting."""
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
|
|
from blueprints.conversation import context
|
|
from blueprints.conversation.context import (
|
|
IMAGE_PLACEHOLDER,
|
|
build_context,
|
|
message_tokens,
|
|
trim_history,
|
|
)
|
|
|
|
|
|
def call(*ids):
|
|
return AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{"id": i, "name": "search", "args": {"path": "notes/budget.md"}}
|
|
for i in ids
|
|
],
|
|
)
|
|
|
|
|
|
def result(i, content="found"):
|
|
return ToolMessage(content=content, tool_call_id=i)
|
|
|
|
|
|
def cost(messages):
|
|
return sum(message_tokens(m) for m in messages)
|
|
|
|
|
|
def test_long_current_turn_keeps_question_and_all_exchanges():
|
|
question = HumanMessage(content="Compare my spending with my plan")
|
|
messages = [question]
|
|
for i in range(8):
|
|
messages += [call(str(i)), result(str(i), "data " * 1000)]
|
|
out = trim_history(messages, token_budget=3000)
|
|
assert out[0] is question
|
|
assert len(out) == 17
|
|
assert cost(out) <= 3000
|
|
assert [m.tool_call_id for m in out if isinstance(m, ToolMessage)] == [
|
|
str(i) for i in range(8)
|
|
]
|
|
assert messages[2].content == "data " * 1000
|
|
assert out[1].tool_calls == messages[1].tool_calls
|
|
|
|
|
|
def test_parallel_tool_results_remain_paired():
|
|
messages = [
|
|
HumanMessage(content="q"),
|
|
call("a", "b"),
|
|
result("a", "x" * 20000),
|
|
result("b", "y" * 20000),
|
|
]
|
|
out = trim_history(messages, token_budget=700)
|
|
assert len(out) == 4
|
|
assert out[1].tool_calls == messages[1].tool_calls
|
|
assert cost(out) <= 700
|
|
|
|
|
|
def test_drops_whole_turns_and_includes_bounded_excerpts():
|
|
messages = []
|
|
for i in range(12):
|
|
messages += [
|
|
HumanMessage(content=f"Question {i}"),
|
|
call(str(i)),
|
|
result(str(i), "data " * 1000),
|
|
AIMessage(content=f"Decision {i}"),
|
|
]
|
|
question = HumanMessage(content="What did we decide?")
|
|
messages.append(question)
|
|
out = trim_history(messages, token_budget=3000)
|
|
assert isinstance(out[0], AIMessage)
|
|
assert "Decision" in out[0].content
|
|
assert "historical data" in out[0].content
|
|
assert cost(out) <= 3000
|
|
assert out[-1] is question
|
|
# Every retained result still has its original call preceding it.
|
|
seen = set()
|
|
for m in out:
|
|
if isinstance(m, AIMessage):
|
|
seen.update(c["id"] for c in m.tool_calls)
|
|
if isinstance(m, ToolMessage):
|
|
assert m.tool_call_id in seen
|
|
|
|
|
|
def test_earlier_images_replaced_current_image_preserved():
|
|
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
|
|
messages = [
|
|
HumanMessage(content=[{"type": "text", "text": "old"}, image]),
|
|
AIMessage(content="photo"),
|
|
HumanMessage(content=[{"type": "text", "text": "new"}, image]),
|
|
]
|
|
out = trim_history(messages)
|
|
assert out[0].content[1] == {"type": "text", "text": IMAGE_PLACEHOLDER}
|
|
assert out[-1].content[1] == image
|
|
assert messages[0].content[1] == image
|
|
|
|
|
|
def test_short_history_unchanged():
|
|
messages = [HumanMessage(content="hi"), AIMessage(content="hello")]
|
|
assert trim_history(messages) == messages
|
|
|
|
|
|
def test_question_that_cannot_fit_fails_without_truncation():
|
|
messages = [HumanMessage(content="important request " * 1000)]
|
|
with pytest.raises(ValueError, match="current question"):
|
|
trim_history(messages, token_budget=100)
|
|
assert messages[0].content == "important request " * 1000
|
|
|
|
|
|
def test_system_and_tool_schemas_are_charged_to_budget(monkeypatch):
|
|
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 2500)
|
|
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 10000)
|
|
schemas = [{"name": "search", "description": "tool docs " * 100}]
|
|
messages = [HumanMessage(content="q"), call("a"), result("a", "result " * 3000)]
|
|
out = build_context(messages, "system " * 100, schemas)
|
|
assert cost(out) + context.estimate_tokens(schemas) <= 2500
|
|
assert out[1] is messages[0]
|
|
|
|
|
|
def test_fixed_overhead_that_cannot_fit_fails(monkeypatch):
|
|
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 100)
|
|
with pytest.raises(ValueError, match="tool definitions"):
|
|
build_context([HumanMessage(content="q")], "system" * 1000, [])
|
|
|
|
|
|
def test_response_and_safety_reserves_reduce_prompt_limit(monkeypatch):
|
|
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 8000)
|
|
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 16000)
|
|
messages = [HumanMessage(content="q"), call("a"), result("a", "data " * 5000)]
|
|
out = build_context(messages, "system", [])
|
|
assert (
|
|
cost(out) + context.estimate_tokens([])
|
|
<= 8000 - context.RESPONSE_RESERVE_TOKENS - context.SAFETY_RESERVE_TOKENS
|
|
)
|
|
|
|
|
|
def test_cancelled_turn_does_not_replay_incomplete_exchange():
|
|
messages = [
|
|
HumanMessage(content="old"),
|
|
call("a", "b"),
|
|
result("a"),
|
|
HumanMessage(content="new"),
|
|
]
|
|
out = trim_history(messages)
|
|
assert out == [messages[0], messages[-1]]
|
|
|
|
|
|
def test_orphan_results_are_removed():
|
|
messages = [
|
|
result("orphan"),
|
|
HumanMessage(content="q"),
|
|
result("another orphan"),
|
|
AIMessage(content="answer"),
|
|
]
|
|
assert trim_history(messages) == [messages[1], messages[3]]
|
|
|
|
|
|
def test_large_assistant_answer_is_bounded_in_earlier_turn():
|
|
messages = [
|
|
HumanMessage(content="old"),
|
|
AIMessage(content="long " * 10000),
|
|
HumanMessage(content="new"),
|
|
]
|
|
out = trim_history(messages, token_budget=4000)
|
|
assert cost(out) <= 4000
|
|
assert len(out[1].content) < len(messages[1].content)
|
|
assert messages[1].content == "long " * 10000
|
|
|
|
|
|
def test_multiblock_attachment_shares_one_cap():
|
|
messages = [
|
|
HumanMessage(content=[{"type": "text", "text": "x" * 10000} for _ in range(5)]),
|
|
AIMessage(content="ok"),
|
|
HumanMessage(content="next"),
|
|
]
|
|
out = trim_history(messages, token_budget=5000)
|
|
assert cost(out) <= 5000
|
|
assert sum(len(p["text"]) for p in out[0].content) < 5000
|
|
|
|
|
|
async def test_saved_memories_are_bounded_without_deleting(monkeypatch):
|
|
from blueprints.conversation import memory
|
|
|
|
saved = ["newest preference", "big " * 5000, "older preference"]
|
|
|
|
async def get_memories(user_id):
|
|
return saved
|
|
|
|
monkeypatch.setattr(memory, "get_memories_for_user", get_memories)
|
|
prompt = await memory.build_system_prompt_with_memories("user")
|
|
block = prompt.split("USER MEMORIES", 1)[1]
|
|
assert "newest preference" in block
|
|
assert "older preference" in block
|
|
assert "omitted" in block
|
|
assert "big big" not in block
|
|
assert saved[1] == "big " * 5000
|
|
|
|
|
|
def test_prompt_accounts_for_selected_tool_schemas(monkeypatch):
|
|
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
|
from blueprints.conversation import agents
|
|
|
|
extra = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "extra",
|
|
"description": "extra docs",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
seen = {}
|
|
|
|
def build(messages, system, schemas, **kwargs):
|
|
seen["schemas"] = schemas
|
|
return messages
|
|
|
|
monkeypatch.setattr(agents, "build_context", build)
|
|
messages = [HumanMessage(content="hello")]
|
|
assert (
|
|
agents._build_prompt({"messages": messages}, tool_schemas=[extra]) == messages
|
|
)
|
|
assert extra in seen["schemas"]
|
|
|
|
|
|
def test_heavy_current_turn_leaves_room_for_previous_decisions():
|
|
question = HumanMessage(content="Apply that plan to my spending")
|
|
messages = [
|
|
HumanMessage(content="Plan?"),
|
|
AIMessage(content="Keep dining under $300."),
|
|
question,
|
|
call("a"),
|
|
result("a", "spending " * 10000),
|
|
]
|
|
out = trim_history(messages, token_budget=2500)
|
|
assert cost(out) <= 2500
|
|
assert question in out
|
|
assert any(
|
|
"Keep dining under $300." in m.content
|
|
for m in out
|
|
if isinstance(m.content, str)
|
|
)
|
|
|
|
|
|
def test_large_tool_arguments_fail_explicitly_instead_of_breaking_pair():
|
|
exchange = call("a")
|
|
exchange.tool_calls[0]["args"] = {"query": "huge " * 10000}
|
|
messages = [HumanMessage(content="q"), exchange, result("a")]
|
|
with pytest.raises(ValueError, match="tool calls exceed"):
|
|
trim_history(messages, token_budget=300)
|
|
assert exchange.tool_calls[0]["args"]["query"] == "huge " * 10000
|
|
|
|
|
|
def test_unicode_tool_result_fits_estimated_budget():
|
|
messages = [HumanMessage(content="q"), call("a"), result("a", "猫😺预算" * 10000)]
|
|
out = trim_history(messages, token_budget=600)
|
|
assert cost(out) <= 600
|
|
assert out[0] is messages[0]
|
|
assert out[1].tool_calls == messages[1].tool_calls
|
|
|
|
|
|
def test_eight_tool_finance_scenario_preserves_structured_evidence():
|
|
import copy
|
|
import json
|
|
|
|
question = HumanMessage(
|
|
content="Compare September spending with my plan; exclude transfers."
|
|
)
|
|
messages = [question]
|
|
kinds = [
|
|
"ynab.summary",
|
|
"ynab.transactions",
|
|
"ynab.spending_report",
|
|
"ynab.category_budget",
|
|
"search.documents",
|
|
"obsidian.note",
|
|
"ynab.transactions",
|
|
"ynab.spending_report",
|
|
]
|
|
for i, kind in enumerate(kinds):
|
|
data = {
|
|
"start_date": "2026-09-01",
|
|
"end_date": "2026-09-30",
|
|
"filters": {"exclude_transfers": True, "budget_id": "budget-123"},
|
|
"total": -1234.56,
|
|
"count": 30,
|
|
}
|
|
if kind == "obsidian.note":
|
|
data.update(
|
|
path="notes/budget.md", markdown="Keep dining under $300.\n" * 2000
|
|
)
|
|
else:
|
|
key = (
|
|
"results"
|
|
if kind == "search.documents"
|
|
else "transactions"
|
|
if kind == "ynab.transactions"
|
|
else "rows"
|
|
)
|
|
data[key] = [
|
|
{
|
|
"id": f"{i}-{n}",
|
|
"path": f"notes/{n}.md",
|
|
"date": "2026-09-15",
|
|
"amount": -41.152,
|
|
"snippet": "finance evidence " * 200,
|
|
}
|
|
for n in range(30)
|
|
]
|
|
artifact = {
|
|
"kind": kind,
|
|
"data": data,
|
|
"source_ref": {"system": "ynab", "id": "budget-123"},
|
|
}
|
|
messages += [
|
|
call(str(i)),
|
|
ToolMessage(
|
|
content="formatted report " * 5000,
|
|
artifact=artifact,
|
|
tool_call_id=str(i),
|
|
),
|
|
]
|
|
original = copy.deepcopy(messages)
|
|
out = trim_history(messages, token_budget=10000)
|
|
assert cost(out) <= 10000
|
|
assert len(out) == 17
|
|
assert out[0] is question
|
|
assert messages == original
|
|
for old, projected in zip(messages[2::2], out[2::2]):
|
|
assert projected.artifact == old.artifact
|
|
parsed = json.loads(projected.content)
|
|
assert parsed["source_ref"] == old.artifact["source_ref"]
|
|
for key in ("total", "start_date", "end_date", "filters", "count"):
|
|
assert parsed["data"][key] == old.artifact["data"][key]
|
|
if parsed["kind"] == "obsidian.note":
|
|
assert parsed["data"]["path"] == "notes/budget.md"
|
|
assert parsed["data"]["markdown_compaction"]["omitted_characters"] > 0
|
|
else:
|
|
data = parsed["data"]
|
|
key = next(k for k in ("rows", "transactions", "results") if k in data)
|
|
omitted = data[key + "_compaction"]
|
|
assert omitted["retained_rows"] + omitted["omitted_rows"] == 30
|
|
assert {r["id"] for r in data[key]} | {
|
|
r["id"] for r in omitted["omitted_source_refs"]
|
|
} == {f"{old.tool_call_id}-{n}" for n in range(30)}
|
|
|
|
|
|
def test_json_content_without_artifact_stays_parseable():
|
|
import json
|
|
|
|
payload = {
|
|
"total": 10,
|
|
"filters": {"query": "food"},
|
|
"rows": [{"id": str(i), "memo": "x" * 2000} for i in range(20)],
|
|
}
|
|
source = result("a", json.dumps(payload))
|
|
projected = context.compact_tool_result(source, 900)
|
|
parsed = json.loads(projected.content)
|
|
assert parsed["total"] == 10
|
|
assert parsed["rows_compaction"]["omitted_rows"] > 0
|
|
assert json.loads(source.content) == payload
|
|
|
|
|
|
def test_unknown_result_is_marked_excerpt():
|
|
projected = context.compact_tool_result(result("a", "unknown prose " * 1000), 100)
|
|
assert "tool result excerpt" in projected.content
|
|
|
|
|
|
def test_structural_metadata_floor_fails_instead_of_corrupting_json():
|
|
message = ToolMessage(
|
|
content="short", artifact={"source_ref": {"id": "x" * 2000}}, tool_call_id="a"
|
|
)
|
|
with pytest.raises(ValueError, match="required tool metadata"):
|
|
trim_history([HumanMessage(content="q"), call("a"), message], token_budget=400)
|
|
|
|
|
|
def test_summary_role_and_budget_are_enforced():
|
|
from langchain_core.messages import SystemMessage
|
|
|
|
with pytest.raises(ValueError, match="assistant-role"):
|
|
trim_history(
|
|
[HumanMessage(content="q")],
|
|
summary_message=SystemMessage(content="untrusted"),
|
|
)
|
|
question = HumanMessage(content="Current request " * 10)
|
|
assert trim_history(
|
|
[question], token_budget=100, summary_message=AIMessage(content="x" * 1000)
|
|
) == [question]
|
|
with pytest.raises(ValueError, match="without tool calls"):
|
|
trim_history([HumanMessage(content="q")], summary_message=call("a"))
|
|
|
|
|
|
def test_nested_source_refs_and_dates_survive_row_omission():
|
|
import json
|
|
|
|
payload = {
|
|
"rows": [
|
|
{
|
|
"metadata": {
|
|
"source_ids": ["one", "two"],
|
|
"start_date": "2026-09-01",
|
|
"path": "notes/plan.md",
|
|
},
|
|
"snippet": "x" * 1000,
|
|
}
|
|
]
|
|
}
|
|
message = ToolMessage(content="display", artifact=payload, tool_call_id="a")
|
|
parsed = json.loads(context.compact_tool_result(message, 0).content)
|
|
assert (
|
|
parsed["rows_compaction"]["omitted_source_refs"][0]["metadata"]
|
|
== payload["rows"][0]["metadata"]
|
|
)
|
|
|
|
|
|
def test_repeated_json_only_compaction_preserves_all_source_ids_and_counts():
|
|
import json
|
|
|
|
original = {
|
|
"rows": [{"id": f"source-{i}", "memo": "detail " * 1000} for i in range(30)],
|
|
"total": 300,
|
|
}
|
|
message = result("a", json.dumps(original))
|
|
first = context.compact_tool_result(message, 3000)
|
|
second = context.compact_tool_result(first, 1500)
|
|
for projected in (first, second):
|
|
parsed = json.loads(projected.content)
|
|
metadata = parsed["rows_compaction"]
|
|
assert metadata["retained_rows"] == len(parsed["rows"])
|
|
assert metadata["retained_rows"] + metadata["omitted_rows"] == 30
|
|
ids = {r["id"] for r in parsed["rows"]} | {
|
|
r["id"] for r in metadata["omitted_source_refs"]
|
|
}
|
|
assert ids == {f"source-{i}" for i in range(30)}
|
|
out = trim_history(
|
|
[HumanMessage(content="q"), call("a"), message], token_budget=1000
|
|
)
|
|
parsed = json.loads(out[-1].content)
|
|
assert parsed["rows_compaction"]["retained_rows"] == len(parsed["rows"])
|
|
ids = {r["id"] for r in parsed["rows"]} | {
|
|
r["id"] for r in parsed["rows_compaction"]["omitted_source_refs"]
|
|
}
|
|
assert len(ids) == 30
|
|
assert cost(out) <= 1000
|
|
|
|
|
|
def test_focused_note_heading_index_uses_absolute_content_lines():
|
|
import json
|
|
|
|
artifact = {
|
|
"kind": "obsidian.note",
|
|
"data": {"path": "late.md", "markdown": "full original"},
|
|
"model_context": {
|
|
"markdown": "# Requested section\n" + "detail\n" * 2000,
|
|
"read_range": {"start_line": 1001, "end_line": 3001, "total_lines": 4000},
|
|
},
|
|
}
|
|
msg = ToolMessage(content="focused read", artifact=artifact, tool_call_id="a")
|
|
projected = context.compact_tool_result(msg, 1500)
|
|
metadata = json.loads(projected.content)["data"]["markdown_compaction"]
|
|
assert metadata["headings"] == [{"line": 1001, "heading": "# Requested section"}]
|
|
assert msg.artifact["data"]["markdown"] == "full original"
|
|
|
|
|
|
def _conversation(turns, size=1000):
|
|
messages = []
|
|
for i in range(turns):
|
|
messages += [
|
|
HumanMessage(content=f"Question {i}"),
|
|
call(str(i)),
|
|
result(str(i), "data " * size),
|
|
AIMessage(content=f"Decision {i}"),
|
|
]
|
|
return messages
|
|
|
|
|
|
def test_summary_omitted_when_no_turns_dropped():
|
|
messages = _conversation(2, size=10) + [HumanMessage(content="next")]
|
|
summary = AIMessage(content="Earlier: keep dining low")
|
|
assert trim_history(messages, summary_message=summary) == trim_history(messages)
|
|
assert summary not in trim_history(messages, summary_message=summary)
|
|
|
|
|
|
def assert_valid_sequence(out):
|
|
seen = set()
|
|
for m in out:
|
|
if isinstance(m, AIMessage):
|
|
seen.update(c["id"] for c in m.tool_calls)
|
|
if isinstance(m, ToolMessage):
|
|
assert m.tool_call_id in seen
|
|
|
|
|
|
def test_summary_placed_before_current_turn_when_turns_dropped():
|
|
question = HumanMessage(content="next")
|
|
# The current turn has its own tool exchange the summary must not split.
|
|
messages = _conversation(12) + [question, call("now"), result("now")]
|
|
summary = AIMessage(content="Earlier: keep dining low")
|
|
out = trim_history(messages, token_budget=6000, summary_message=summary)
|
|
assert cost(out) <= 6000
|
|
assert out[-4:] == [summary, question, messages[-2], messages[-1]]
|
|
assert out.count(summary) == 1
|
|
# Excerpts of dropped turns stay at the head; they only change with the cut.
|
|
assert out[0].content.startswith("Earlier conversation excerpts")
|
|
assert_valid_sequence(out)
|
|
|
|
|
|
def test_changing_summary_leaves_retained_prefix_identical():
|
|
messages = _conversation(12) + [HumanMessage(content="next")]
|
|
short = AIMessage(content="Earlier: keep dining low")
|
|
long = AIMessage(content="Earlier: " + "constraint " * 400)
|
|
a = trim_history(messages, token_budget=6000, summary_message=short)
|
|
b = trim_history(messages, token_budget=6000, summary_message=long)
|
|
assert a[: a.index(short)] == b[: b.index(long)]
|
|
|
|
|
|
def test_trim_drops_to_low_watermark_and_prefix_stays_stable():
|
|
budget = 8000
|
|
messages, oldest, costs = [], [], []
|
|
for i in range(30):
|
|
messages.append(HumanMessage(content=f"Question {i}"))
|
|
out = trim_history(messages, token_budget=budget)
|
|
assert out[-1] is messages[-1]
|
|
assert cost(out) <= budget
|
|
oldest.append(next(m for m in out if isinstance(m, HumanMessage)).content)
|
|
costs.append(cost(out))
|
|
messages += [call(str(i)), result(str(i), "data " * 300), AIMessage("ok")]
|
|
cuts = [i for i in range(1, 30) if oldest[i] != oldest[i - 1]]
|
|
assert cuts, "history should overflow the budget"
|
|
# Each drop goes well below the budget instead of one turn under it...
|
|
for i in cuts:
|
|
assert costs[i] <= budget * (context.TRIM_LOW_WATERMARK + 0.1)
|
|
# ...so the retained prefix is unchanged for several turns in between.
|
|
assert all(b - a >= 3 for a, b in zip(cuts, cuts[1:]))
|
|
assert len(cuts) <= 5
|