haiku.rag/tests/capabilities/test_evidence_wire.py
Yiorgis Gozadinos 3ec72363bc
Let the record say whether a question is still being answered
Resumption was inferred from the transcript, and no shape says it. A missing
prompt is how UI adapters ask their first question as much as how pydantic-ai
resumes one, so every AG-UI host failed on its first message. Reading the tail
instead moved the error rather than fixing it: a settled structured answer ends
with an output tool's return, indistinguishable from results delivered to a
question still in progress, so following questions inherited the first one's
identity.

`CapabilityEvidenceRecord.in_progress` is now the authority. `begin_question`
sets it, `after_run` clears it unless the run is only pausing for deferred work,
and a run that raised never reaches `after_run`, which is what leaves an
interrupted question claimable. The history is consulted only to catch a host
that dropped the state of a question the model is unmistakably still owed.
2026-08-13 15:03:24 +03:00

729 lines
24 KiB
Python

import base64
from dataclasses import dataclass, field, replace
from typing import Any, cast
from unittest.mock import AsyncMock, patch
import pytest
from pydantic_ai import Agent, DeferredToolResults, RunContext
from pydantic_ai.messages import (
BinaryContent,
ModelRequest,
ModelResponse,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models import ModelRequestContext, ModelRequestParameters
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RunUsage
from haiku.rag.capabilities.compaction import (
RECEIPT,
Capsule,
compact_history,
picture_label,
)
from haiku.rag.capabilities.compaction import create_capability as create_compaction
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
from haiku.rag.capabilities.rag import RAGCapability, RAGState
from haiku.rag.capabilities.rag import create_capability as create_rag
from haiku.rag.config.models import AppConfig
from haiku.rag.store.models.chunk import SearchResult
OWNED = frozenset({"rag_search"})
PNG = BinaryContent(data=b"fake-image-bytes", media_type="image/png")
def retrieved_image(chunk_id: str = "chunk-1", self_ref: str = "#/pictures/0"):
"""A page image on the wire, labelled the way a search result attaches it."""
return UserPromptPart(content=[picture_label(chunk_id, self_ref), PNG])
def answered_question(question: str, *, evidence: str, images: bool = False):
"""One settled question: prompt, search, result, answer."""
returned: list[Any] = [ToolReturnPart("rag_search", evidence, "call-1")]
if images:
returned.append(retrieved_image())
return [
ModelRequest(parts=[UserPromptPart(question)]),
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-1")]),
ModelRequest(parts=returned),
ModelResponse(parts=[TextPart("an answer")]),
]
def returns_of(messages) -> list[str]:
return [
str(part.content)
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
def images_of(messages) -> list[BinaryContent]:
return [
item
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, UserPromptPart) and not isinstance(part.content, str)
for item in part.content
if isinstance(item, BinaryContent)
]
def texts_of(messages) -> list[str]:
return [
item
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, UserPromptPart) and not isinstance(part.content, str)
for item in part.content
if isinstance(item, str)
]
def test_nothing_before_the_first_question_is_compacted():
messages = answered_question("first", evidence="EVIDENCE")
compacted = compact_history(
messages, boundary=0, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert compacted == messages
def test_the_newest_earlier_return_carries_the_capsule():
messages = [
*answered_question("first", evidence="OLD EVIDENCE"),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert returns_of(compacted) == ["CAPSULE"]
assert "OLD EVIDENCE" not in returns_of(compacted)
def test_older_returns_become_receipts_and_only_the_newest_carries_the_capsule():
messages = [
*answered_question("first", evidence="OLDEST"),
*answered_question("second", evidence="NEWER"),
ModelRequest(parts=[UserPromptPart("third")]),
]
compacted = compact_history(
messages, boundary=8, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert returns_of(compacted) == [RECEIPT, "CAPSULE"]
def test_the_current_question_keeps_its_own_evidence():
messages = [
*answered_question("first", evidence="OLD EVIDENCE"),
ModelRequest(parts=[UserPromptPart("second")]),
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-2")]),
ModelRequest(parts=[ToolReturnPart("rag_search", "LIVE EVIDENCE", "call-2")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert returns_of(compacted) == ["CAPSULE", "LIVE EVIDENCE"]
def test_another_capabilitys_return_is_left_alone():
messages = [
ModelRequest(parts=[UserPromptPart("first")]),
ModelResponse(parts=[ToolCallPart("other_tool", {}, "call-1")]),
ModelRequest(parts=[ToolReturnPart("other_tool", "NOT OURS", "call-1")]),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert returns_of(compacted) == ["NOT OURS"]
def test_a_cite_acknowledgement_survives():
"""A receipt of the model's own action, not evidence."""
messages = [
ModelRequest(parts=[UserPromptPart("first")]),
ModelResponse(
parts=[ToolCallPart("rag_cite", {"chunk_ids": ["c1"]}, "call-1")]
),
ModelRequest(
parts=[ToolReturnPart("rag_cite", "Registered 1 citation.", "c1")]
),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert returns_of(compacted) == ["Registered 1 citation."]
def test_an_uncited_earlier_image_is_dropped_with_its_label():
messages = [
*answered_question("first", evidence="OLD", images=True),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert images_of(compacted) == []
assert texts_of(compacted) == []
def test_cited_pictures_are_attached_beside_the_capsule():
messages = [
*answered_question("first", evidence="OLD", images=True),
ModelRequest(parts=[UserPromptPart("second")]),
]
fresh = BinaryContent(data=b"cited-bytes", media_type="image/png")
compacted = compact_history(
messages,
boundary=4,
owned_tools=OWNED,
capsule_text="CAPSULE",
capsule_images=[picture_label("cited-chunk", "#/pictures/3"), fresh],
)
assert images_of(compacted) == [fresh]
assert texts_of(compacted) == [picture_label("cited-chunk", "#/pictures/3")]
carrier = [
index
for index, message in enumerate(compacted)
if isinstance(message, ModelRequest)
and any(
isinstance(part, ToolReturnPart) and part.content == "CAPSULE"
for part in message.parts
)
]
attached = [
index
for index, message in enumerate(compacted)
if isinstance(message, ModelRequest)
and any(
isinstance(part, UserPromptPart) and not isinstance(part.content, str)
for part in message.parts
)
]
assert carrier == attached
def test_a_user_attached_image_is_never_dropped():
"""The user's own picture is not ours to remove, even in an earlier question."""
mine = UserPromptPart(content=["look at this", PNG])
messages = [
ModelRequest(parts=[mine]),
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-1")]),
ModelRequest(parts=[ToolReturnPart("rag_search", "OLD", "call-1")]),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert images_of(compacted) == [PNG]
assert texts_of(compacted) == ["look at this"]
def test_the_stored_messages_are_never_mutated():
messages = [
*answered_question("first", evidence="OLD EVIDENCE", images=True),
ModelRequest(parts=[UserPromptPart("second")]),
]
before = [list(message.parts) for message in messages]
compact_history(messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE")
assert [list(message.parts) for message in messages] == before
assert "OLD EVIDENCE" in returns_of(messages)
def test_nothing_cited_leaves_only_receipts():
messages = [
*answered_question("first", evidence="OLD"),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text=""
)
assert returns_of(compacted) == [RECEIPT]
@dataclass
class Deps:
state: dict[str, Any] = field(default_factory=dict)
def rag_and_compactor(temp_db_path):
return (
create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False),
create_compaction(),
)
def in_flight_history() -> list[Any]:
"""A question already asked and searched, still awaiting its answer."""
return [
ModelRequest(parts=[UserPromptPart("what does the supervisor do?")]),
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "s"}, "call-1")]),
ModelRequest(
parts=[ToolReturnPart("rag_search", "EVIDENCE FOR THE LIVE TURN", "call-1")]
),
]
def resuming_deps(question: int = 0) -> Deps:
"""State as a resumption always finds it: the question identified and unfinished."""
return Deps(
state={
"rag": RAGState(
evidence=CapabilityEvidenceRecord(question=question, in_progress=True)
).model_dump(mode="json")
}
)
@pytest.mark.asyncio
async def test_without_the_compactor_the_history_is_untouched(temp_db_path):
"""Omission is the switch: there is no flag to test, only absence."""
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
wire: list[list[Any]] = []
async def model(messages, _info):
wire.append(list(messages))
return ModelResponse(parts=[TextPart("answer")])
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
settled = [*in_flight_history(), ModelResponse(parts=[TextPart("first answer")])]
await agent.run("a different question", deps=Deps(), message_history=settled)
assert returns_of(wire[-1]) == ["EVIDENCE FOR THE LIVE TURN"]
@pytest.mark.asyncio
async def test_with_the_compactor_a_new_question_compacts_the_previous_one(
temp_db_path,
):
rag, compactor = rag_and_compactor(temp_db_path)
wire: list[list[Any]] = []
async def model(messages, _info):
wire.append(list(messages))
return ModelResponse(parts=[TextPart("answer")])
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag, compactor])
settled = [*in_flight_history(), ModelResponse(parts=[TextPart("first answer")])]
await agent.run("a different question", deps=Deps(), message_history=settled)
assert returns_of(wire[-1]) == [RECEIPT]
@pytest.mark.parametrize(
"resume_kwargs",
[
pytest.param({}, id="no prompt"),
pytest.param(
{"deferred_tool_results": DeferredToolResults()}, id="deferred results"
),
],
)
@pytest.mark.asyncio
async def test_a_resumed_question_keeps_the_evidence_it_is_answering_from(
temp_db_path, resume_kwargs
):
"""The boundary is the stored identity of the question in progress.
An earlier question below it is compacted; the evidence the model is still
answering from sits above it and survives. Deriving the boundary from message
shape instead would put the live evidence below it and answer with a receipt
where the search result should be.
"""
rag, compactor = rag_and_compactor(temp_db_path)
wire: list[list[Any]] = []
async def model(messages, _info):
wire.append(list(messages))
return ModelResponse(parts=[TextPart("answer")])
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag, compactor])
history = [
*answered_question("an earlier question", evidence="EVIDENCE FOR THE OLD TURN"),
*in_flight_history(),
]
await agent.run(
deps=resuming_deps(question=4), message_history=history, **resume_kwargs
)
assert returns_of(wire[-1]) == [RECEIPT, "EVIDENCE FOR THE LIVE TURN"]
@pytest.mark.asyncio
async def test_compaction_never_reaches_the_stored_message_history(temp_db_path):
"""Rewriting is for the wire; hosts keep the evidence they gathered."""
rag, compactor = rag_and_compactor(temp_db_path)
turns = iter(
[
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-1")]),
ModelResponse(parts=[TextPart("first answer")]),
ModelResponse(parts=[TextPart("second answer")]),
]
)
async def model(_messages, _info):
return next(turns)
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag, compactor])
deps = Deps()
with patch.object(
RAGCapability, "_search", AsyncMock(return_value="REAL EVIDENCE")
):
first = await agent.run("old question", deps=deps)
second = await agent.run(
"current question", deps=deps, message_history=first.all_messages()
)
assert "REAL EVIDENCE" in returns_of(second.all_messages())
assert RECEIPT not in returns_of(second.all_messages())
REAL_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
)
async def _search_with_a_picture(self, query: str, _limit: int | None) -> str:
"""Record a result carrying a page image, the way a real search does."""
cast(Any, self.state).searches[query] = [
SearchResult(
content="evidence",
score=1.0,
chunk_id="chunk-1",
document_id="doc-1",
doc_item_refs=["#/pictures/0"],
)
]
self._note_evidence()
return "EVIDENCE"
async def _cite_a_picture_chunk(temp_db_path, fetched: bytes | None):
"""Two questions: cite a picture chunk, then ask something else."""
rag, compactor = rag_and_compactor(temp_db_path)
calls = iter(
[
[ToolCallPart("rag_search", {"query": "supervisor"}, "call-1")],
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-2")],
[TextPart("first answer")],
[TextPart("second answer")],
]
)
wire: list[list[Any]] = []
async def model(messages, _info):
wire.append(list(messages))
return ModelResponse(parts=next(calls))
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag, compactor])
deps = Deps()
with (
patch.object(RAGCapability, "_search", _search_with_a_picture),
patch.object(
RAGCapability, "get_picture_bytes", AsyncMock(return_value=fetched)
),
):
first = await agent.run("what does the supervisor do?", deps=deps)
await agent.run(
"and what else?", deps=deps, message_history=first.all_messages()
)
return wire
@pytest.mark.asyncio
async def test_a_cited_picture_is_fetched_and_attached_with_its_label(temp_db_path):
wire = await _cite_a_picture_chunk(temp_db_path, REAL_PNG)
assert [picture.data for picture in images_of(wire[-1])] == [REAL_PNG]
assert texts_of(wire[-1]) == [picture_label("chunk-1", "#/pictures/0")]
@pytest.mark.asyncio
async def test_a_picture_that_cannot_be_fetched_emits_neither_image_nor_label(
temp_db_path,
):
"""A label without its picture tells the model a figure is there when it is not."""
wire = await _cite_a_picture_chunk(temp_db_path, None)
assert images_of(wire[-1]) == []
assert texts_of(wire[-1]) == []
@pytest.mark.asyncio
async def test_a_picture_that_will_not_decode_emits_neither_image_nor_label(
temp_db_path,
):
"""One vision placeholder is rendered per attachment, so a corrupt one miscounts."""
wire = await _cite_a_picture_chunk(temp_db_path, b"not-an-image")
assert images_of(wire[-1]) == []
assert texts_of(wire[-1]) == []
@pytest.mark.asyncio
async def test_the_capsule_is_built_once_per_request_and_again_for_the_next(
temp_db_path,
):
"""Two hook passes for one request must not rebuild; the next request must."""
rag, compactor = rag_and_compactor(temp_db_path)
deps = Deps()
ctx = RunContext(
deps=deps, model=TestModel(), usage=RunUsage(), run_id="run-1", run_step=1
)
run_rag = await rag.for_run(ctx)
run_compactor = await compactor.for_run(ctx)
cast(Any, run_rag.state).evidence.begin_question(4)
ctx = replace(ctx, capabilities={"rag": run_rag, "compaction": run_compactor})
builds = 0
def counting_build(evidence):
nonlocal builds
builds += 1
return Capsule(text="CAPSULE")
async def handler(_request_context):
return ModelResponse(parts=[TextPart("answer")])
request = ModelRequestContext(
messages=[*answered_question("first", evidence="OLD")],
model=TestModel(),
model_request_parameters=ModelRequestParameters(),
model_settings=None,
)
with patch("haiku.rag.capabilities.compaction.build_capsule", counting_build):
await run_compactor.wrap_model_request(
ctx, request_context=request, handler=handler
)
await run_compactor.wrap_model_request(
ctx, request_context=request, handler=handler
)
assert builds == 1
await run_compactor.wrap_model_request(
replace(ctx, run_step=2), request_context=request, handler=handler
)
assert builds == 2
def test_a_user_quoting_our_wording_keeps_their_picture_and_their_text():
"""Prose is not proof of ownership: a user can write any phrase.
Recognising our own pictures by a natural-language substring removed a user's
image, its text, and with it the whole message part.
"""
quoted = UserPromptPart(
content=[
"Here is a page image retrieved from the knowledge base for my report",
PNG,
]
)
messages = [
ModelRequest(parts=[quoted]),
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-1")]),
ModelRequest(parts=[ToolReturnPart("rag_search", "OLD", "call-1")]),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert images_of(compacted) == [PNG]
assert texts_of(compacted) == [
"Here is a page image retrieved from the knowledge base for my report"
]
assert all(message.parts for message in compacted)
def test_a_label_of_ours_with_no_picture_after_it_is_kept():
"""Only a genuine pair is ours to remove; a lone label is someone else's text."""
lonely = UserPromptPart(
content=[picture_label("chunk-1", "#/pictures/0"), "and more"]
)
messages = [
ModelRequest(parts=[lonely]),
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-1")]),
ModelRequest(parts=[ToolReturnPart("rag_search", "OLD", "call-1")]),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert texts_of(compacted) == [
picture_label("chunk-1", "#/pictures/0"),
"and more",
]
@pytest.mark.asyncio
async def test_a_picture_whose_fetch_raises_costs_the_picture_not_the_answer(
temp_db_path,
):
rag, compactor = rag_and_compactor(temp_db_path)
calls = iter(
[
[ToolCallPart("rag_search", {"query": "supervisor"}, "call-1")],
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-2")],
[TextPart("first answer")],
[TextPart("second answer")],
]
)
wire: list[list[Any]] = []
async def model(messages, _info):
wire.append(list(messages))
return ModelResponse(parts=next(calls))
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag, compactor])
deps = Deps()
with (
patch.object(RAGCapability, "_search", _search_with_a_picture),
patch.object(
RAGCapability,
"get_picture_bytes",
AsyncMock(side_effect=OSError("the read failed")),
),
):
first = await agent.run("what does the supervisor do?", deps=deps)
second = await agent.run(
"and what else?", deps=deps, message_history=first.all_messages()
)
assert second.output == "second answer"
assert images_of(wire[-1]) == []
assert texts_of(wire[-1]) == []
def test_a_request_is_never_left_with_no_parts():
"""Emptying a message would leave something that is not a message.
Our own pictures always travel with the tool return in their request, so this
shape does not come from us — but a rewritten history can hold it, and a
partless request is invalid whatever produced it.
"""
ours_alone = ModelRequest(
parts=[UserPromptPart(content=[picture_label("chunk-1", "#/pictures/0"), PNG])]
)
messages = [
ours_alone,
ModelResponse(parts=[ToolCallPart("rag_search", {"query": "q"}, "call-1")]),
ModelRequest(parts=[ToolReturnPart("rag_search", "OLD", "call-1")]),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert all(message.parts for message in compacted)
assert compacted[0] is ours_alone
def test_two_owned_returns_in_one_request_yield_one_capsule():
"""A model can search twice in one response, so a request can hold two returns.
Identifying the carrier by message alone gave every return in it the capsule,
which duplicates the whole thing — and it is unbounded.
"""
messages = [
ModelRequest(parts=[UserPromptPart("first")]),
ModelResponse(
parts=[
ToolCallPart("rag_search", {"query": "a"}, "call-1"),
ToolCallPart("rag_search", {"query": "b"}, "call-2"),
]
),
ModelRequest(
parts=[
ToolReturnPart("rag_search", "FIRST EVIDENCE", "call-1"),
ToolReturnPart("rag_search", "SECOND EVIDENCE", "call-2"),
]
),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
compacted = compact_history(
messages, boundary=4, owned_tools=OWNED, capsule_text="CAPSULE"
)
assert returns_of(compacted) == [RECEIPT, "CAPSULE"]
def test_the_capsule_is_attached_beside_the_newest_return_of_that_request():
messages = [
ModelRequest(parts=[UserPromptPart("first")]),
ModelResponse(
parts=[
ToolCallPart("rag_search", {"query": "a"}, "call-1"),
ToolCallPart("rag_search", {"query": "b"}, "call-2"),
]
),
ModelRequest(
parts=[
ToolReturnPart("rag_search", "FIRST", "call-1"),
ToolReturnPart("rag_search", "SECOND", "call-2"),
]
),
ModelResponse(parts=[TextPart("an answer")]),
ModelRequest(parts=[UserPromptPart("second")]),
]
fresh = BinaryContent(data=b"cited-bytes", media_type="image/png")
compacted = compact_history(
messages,
boundary=4,
owned_tools=OWNED,
capsule_text="CAPSULE",
capsule_images=[picture_label("cited", "#/pictures/1"), fresh],
)
assert images_of(compacted) == [fresh]
assert returns_of(compacted) == [RECEIPT, "CAPSULE"]