Merge pull request #535 from ggozad/feat/evidence-compaction
Record cited evidence, and make history compaction an explicit capability
This commit is contained in:
commit
89b3a9186c
21 changed files with 2858 additions and 136 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -1,12 +1,30 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `EvidenceCompactionCapability` (`haiku.rag.capabilities.compaction.create_capability`): registering it replaces earlier questions' evidence on the model request with the evidence that was cited, grouped by the question that cited it, cited page images re-attached, other earlier evidence returns reduced to a receipt. Requests only; `all_messages()` is untouched. No configuration.
|
||||||
|
- `RAGState.evidence` / `AnalysisState.evidence` (`CapabilityEvidenceRecord`): which evidence a capability retrieved and cited, per question, keyed by message-count question identities and epochs. `haiku.rag.capabilities.ledger.citation_status(records, question=...)` derives `missing` / `grounded` / `ungrounded` across capabilities.
|
||||||
|
- `RAGCapabilityBase.evidence_tool_names()` and `get_picture_bytes()`.
|
||||||
|
- `haiku.rag.tools.search.decode_picture()`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `RAGCapability` and `AnalysisCapability` no longer rewrite the model request. Register `create_capability()` from `haiku.rag.capabilities.compaction` alongside them to keep earlier questions compacted.
|
||||||
|
- Resuming a run (no prompt, deferred tool results, an unfinished history tail) raises `RuntimeError` unless the host carries the capability state from the run being resumed.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `PRIOR_TURN_NOTICE` and `_compact_old_tool_returns` from `haiku.rag.capabilities._base`, and `RAGCapabilityBase.turn_start`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `cross-encoder` reranking no longer ties the scores of strongly-relevant candidates, which left their order to the sort. Scores remain 0-1.
|
- `cross-encoder` reranking no longer ties the scores of strongly-relevant candidates, which left their order to the sort. Scores remain 0-1.
|
||||||
- `haiku-rag` and `haiku-ingester` CLI startup no longer imports `lancedb`, `pyarrow` and `pydantic_ai`.
|
- `haiku-rag` and `haiku-ingester` CLI startup no longer imports `lancedb`, `pyarrow` and `pydantic_ai`.
|
||||||
- `haiku.rag.store` no longer re-exports `Store`; import it from `haiku.rag.store.engine`.
|
- `haiku.rag.store` no longer re-exports `Store`; import it from `haiku.rag.store.engine`.
|
||||||
- `docling-local` reuses one docling `DocumentConverter` per set of conversion options instead of building one per document, so local layout, table and OCR models are no longer loaded per document. Conversions through a shared converter are serialized.
|
- `docling-local` reuses one docling `DocumentConverter` per set of conversion options instead of building one per document, so local layout, table and OCR models are no longer loaded per document. Conversions through a shared converter are serialized.
|
||||||
|
- A resumed run keeps the searches, citations and executions of the question in progress instead of clearing them.
|
||||||
|
- Each page image attached to a search result is preceded by a line giving its position and the chunk id it came from. `build_binary_parts_from_results` is now `build_image_content_from_results` and returns those labels interleaved with the pictures.
|
||||||
|
|
||||||
## [0.73.0] - 2026-08-06
|
## [0.73.0] - 2026-08-06
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.compaction import (
|
||||||
|
create_capability as create_compaction,
|
||||||
|
)
|
||||||
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGState, create_capability
|
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGState, create_capability
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config import load_yaml_config
|
from haiku.rag.config import load_yaml_config
|
||||||
|
|
@ -81,7 +84,9 @@ capability = create_capability(db_path=db_path, config=Config, defer_loading=Fal
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
get_model(Config.qa.model, Config),
|
get_model(Config.qa.model, Config),
|
||||||
instructions=AGENT_PREAMBLE,
|
instructions=AGENT_PREAMBLE,
|
||||||
capabilities=[capability],
|
# Conversations here are multi-turn, so earlier questions are reduced to the
|
||||||
|
# evidence they cited rather than carried whole.
|
||||||
|
capabilities=[capability, create_compaction()],
|
||||||
deps_type=AppDeps,
|
deps_type=AppDeps,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,21 +133,21 @@ Two approaches are benchmarked separately:
|
||||||
| Embedding Model | Reranker | Cases | MAP |
|
| Embedding Model | Reranker | Cases | MAP |
|
||||||
|------------------------------------------|------------------------------------------------------|------:|-------:|
|
|------------------------------------------|------------------------------------------------------|------:|-------:|
|
||||||
| `Qwen/Qwen3-VL-Embedding-8B` | none | 3045 | 0.9774 |
|
| `Qwen/Qwen3-VL-Embedding-8B` | none | 3045 | 0.9774 |
|
||||||
| `nvidia/llama-nemotron-embed-vl-1b-v2` | none | 3045 | 0.9709 |
|
| `nvidia/llama-nemotron-embed-vl-1b-v2` | none | 3045 | 0.9798 |
|
||||||
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `nvidia/llama-nemotron-rerank-vl-1b-v2` (multimodal) | 3045 | 0.9913 |
|
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `nvidia/llama-nemotron-rerank-vl-1b-v2` (multimodal) | 3045 | 0.9913 |
|
||||||
|
|
||||||
*The reranked row uses `reranking.multimodal: true`: picture chunks reach the vision reranker as images alongside their description text. Measured on haiku.rag main post-v0.67.3 (multimodal reranking ships in the next release).*
|
*The nemotron row without a reranker is measured on this release. The reranked row uses `reranking.multimodal: true`: picture chunks reach the vision reranker as images alongside their description text, measured on haiku.rag main post-v0.67.3.*
|
||||||
|
|
||||||
##### QA accuracy + citation retrieval
|
##### QA accuracy + citation retrieval
|
||||||
|
|
||||||
| Embedding Model | Target | Capability model | Cases | QA accuracy | Mean `cited_map` |
|
| Embedding Model | Target | Capability model | Cases | QA accuracy | Mean `cited_map` |
|
||||||
|------------------------------------------|-----------------|-----------------------------------|------:|-------------|------------------|
|
|------------------------------------------|-----------------|-----------------------------------|------:|-------------|------------------|
|
||||||
| `Qwen/Qwen3-VL-Embedding-8B` | `rag-capability` | `vllm:Gemma-4-26B-A4B-NVFP4` | 1409 | 0.89 | — |
|
| `Qwen/Qwen3-VL-Embedding-8B` | `rag-capability` | `vllm:Gemma-4-26B-A4B-NVFP4` | 1409 | 0.89 | — |
|
||||||
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `rag-capability` | `vllm:Gemma-4-26B-A4B-NVFP4` | 3045 | 0.92 | 0.93 |
|
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `rag-capability` | `vllm:Gemma-4-26B-A4B-NVFP4` | 3039 | 0.9263 | 0.9761 |
|
||||||
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Gemma-4-26B-A4B-NVFP4` | 3045 | 0.94 | 0.78 |
|
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Gemma-4-26B-A4B-NVFP4` | 3040 | 0.9362 | 0.9343 |
|
||||||
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Qwen3.6-35B-A3B-NVFP4` | 3045 | 0.95 | 0.93 |
|
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Qwen3.6-35B-A3B-NVFP4` | 3045 | 0.95 | 0.93 |
|
||||||
|
|
||||||
*Measured on haiku.rag v0.52.0, no reranker, judged by `vllm:Qwen3.6-35B-A3B-NVFP4`. Qwen3-VL covered 1409 / 3045 cases.*
|
*Both nemotron `Gemma-4` rows are measured on this release, no reranker, judged by `vllm:Qwen3.6-35B-A3B-NVFP4` with thinking on, and exclude the cases that errored (6 of 3045 for `rag-capability`, 5 for `analysis-capability`). The `rag-capability` row cites at 99.64% with a mean of 1.08 citations per case, at a median 4.7s per case against 5.0s for `analysis-capability`. Citation coverage is what moved on this release: 4.9% of analysis cases register no citation, against 26.3% before, at unchanged searches and code executions per case. The remaining rows are from haiku.rag v0.52.0, where Qwen3-VL covered 1409 / 3045 cases.*
|
||||||
|
|
||||||
#### Text embedder + VLM picture descriptions
|
#### Text embedder + VLM picture descriptions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ async with HaikuRAG("my.lancedb") as client:
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
When dependencies expose a state dictionary, `AnalysisState` is stored under `"analysis"`. It contains the document filter, code execution log, searches, and citations. Per-run searches and executions reset automatically; the filter and citation index persist.
|
When dependencies expose a state dictionary, `AnalysisState` is stored under `"analysis"`. It contains the document filter, code execution log, searches, citations, and the `evidence` record of what was retrieved and cited per question. Searches and executions are cleared when a new question starts, and a resumed question keeps them; the filter, citation index and evidence record persist.
|
||||||
|
|
||||||
|
This capability does not alter the message history either. Register the [compaction capability](index.md#multi-turn-conversations) to compact earlier questions.
|
||||||
|
|
||||||
The capability lazily opens both LanceDB and the sandbox only after it is loaded and a tool requires them. Resources close at the end of the agent run.
|
The capability lazily opens both LanceDB and the sandbox only after it is loaded and a tool requires them. Resources close at the end of the agent run.
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
# Capabilities
|
# Capabilities
|
||||||
|
|
||||||
haiku.rag provides two native [Pydantic AI capabilities](https://ai.pydantic.dev/capabilities/):
|
haiku.rag provides native [Pydantic AI capabilities](https://ai.pydantic.dev/capabilities/):
|
||||||
|
|
||||||
| Capability | Use it for |
|
| Capability | Use it for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`RAGCapability`](rag.md) | Grounded document search and citations. |
|
| [`RAGCapability`](rag.md) | Grounded document search and citations. |
|
||||||
| [`AnalysisCapability`](analysis.md) | Corpus computation and structural analysis with sandboxed Python. |
|
| [`AnalysisCapability`](analysis.md) | Corpus computation and structural analysis with sandboxed Python. |
|
||||||
|
| `EvidenceCompactionCapability` | Optional. Shrinking a conversation's history to the evidence that was cited. |
|
||||||
|
|
||||||
Both capabilities are deferred by default. An agent initially sees only their descriptions and the standard `load_capability` tool. Instructions and tools enter the model context only when the model loads a capability.
|
The two evidence capabilities are deferred by default. An agent initially sees only their descriptions and the standard `load_capability` tool. Instructions and tools enter the model context only when the model loads a capability.
|
||||||
|
|
||||||
## Compose an agent
|
## Compose an agent
|
||||||
|
|
||||||
|
|
@ -34,6 +35,38 @@ agent = Agent(
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-turn conversations
|
||||||
|
|
||||||
|
Every question adds its search results to the history, so requests grow turn after
|
||||||
|
turn, and can degrade answers or exceed a provider's limits as they do. Register the
|
||||||
|
compaction capability to replace earlier questions' evidence with the evidence that
|
||||||
|
was actually cited:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from haiku.rag.capabilities.compaction import create_capability as compaction
|
||||||
|
from haiku.rag.capabilities.rag import create_capability as rag
|
||||||
|
|
||||||
|
agent = Agent(
|
||||||
|
"openai:gpt-5",
|
||||||
|
capabilities=[rag(db_path="my.lancedb"), compaction()],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cited text and cited page images are kept in full, grouped by the question that
|
||||||
|
cited them, and stay citable by the same chunk ids. Everything else earlier becomes a
|
||||||
|
short receipt. Registering the capability is the only switch: leave it out and the
|
||||||
|
transcript reaches the model untouched. There is nothing to configure.
|
||||||
|
|
||||||
|
Compaction rewrites the request, never the stored history, so `all_messages()` still
|
||||||
|
holds everything the run gathered. Retained evidence still grows with the
|
||||||
|
conversation — this reduces what a request carries, it does not bound it. A host that
|
||||||
|
needs more aggressive pruning can compact its own requests further, on the wire only.
|
||||||
|
|
||||||
|
Resuming a question (deferred tool results, an interruption, a suspension) requires
|
||||||
|
the host to carry the capability state from the run being resumed, alongside the
|
||||||
|
message history. Without it the identity of the question in progress is unknowable
|
||||||
|
and the run fails rather than silently treating it as a new question.
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
Capabilities use a plain `state: dict[str, Any]` attribute on agent dependencies when one is available. RAG state lives under `"rag"`; analysis state lives under `"analysis"`. This keeps state independent of any transport or UI protocol.
|
Capabilities use a plain `state: dict[str, Any]` attribute on agent dependencies when one is available. RAG state lives under `"rag"`; analysis state lives under `"analysis"`. This keeps state independent of any transport or UI protocol.
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,19 @@ class RAGState(BaseModel):
|
||||||
citation_index: dict[str, Citation]
|
citation_index: dict[str, Citation]
|
||||||
citations: list[str]
|
citations: list[str]
|
||||||
document_filter: str | None
|
document_filter: str | None
|
||||||
|
evidence: CapabilityEvidenceRecord
|
||||||
searches: dict[str, list[SearchResult]]
|
searches: dict[str, list[SearchResult]]
|
||||||
```
|
```
|
||||||
|
|
||||||
`document_filter` persists between runs. Current citations and searches reset for each run, while the citation index remains available to the host application.
|
`document_filter`, `citation_index` and `evidence` persist across runs. Citations and searches are cleared when a new question starts; a run that resumes a question keeps the evidence it is still answering from.
|
||||||
|
|
||||||
|
`evidence` records which chunks this capability retrieved and cited, and in which question. `haiku.rag.capabilities.ledger.citation_status(records, question=...)` derives `missing`, `grounded` or `ungrounded` from it, across capabilities.
|
||||||
|
|
||||||
State is ordinary application state; the capability does not depend on AG-UI. An AG-UI application can expose it using Pydantic AI's standard adapter.
|
State is ordinary application state; the capability does not depend on AG-UI. An AG-UI application can expose it using Pydantic AI's standard adapter.
|
||||||
|
|
||||||
## Context management
|
## Context management
|
||||||
|
|
||||||
Large RAG tool results from earlier user turns are replaced with a short marker before model requests. Tool-call pairing and current-turn evidence are retained. This prevents long conversations from repeatedly sending old retrieved content.
|
This capability does not alter the message history. To stop long conversations resending old retrieved content, register the [compaction capability](index.md#multi-turn-conversations) alongside it.
|
||||||
|
|
||||||
## Domain context and vision
|
## Domain context and vision
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ from pydantic_ai.messages import (
|
||||||
ModelResponse,
|
ModelResponse,
|
||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
ToolReturn,
|
ToolReturn,
|
||||||
ToolReturnPart,
|
|
||||||
UserPromptPart,
|
|
||||||
)
|
)
|
||||||
from pydantic_ai.models import ModelRequestContext
|
from pydantic_ai.models import ModelRequestContext
|
||||||
from pydantic_ai.run import AgentRunResult
|
from pydantic_ai.run import AgentRunResult
|
||||||
|
|
@ -24,11 +22,12 @@ from pydantic_ai.tools import ToolDefinition
|
||||||
from pydantic_ai.toolsets import AgentToolset
|
from pydantic_ai.toolsets import AgentToolset
|
||||||
|
|
||||||
from haiku.rag.capabilities._tools import CodeExecutionEntry, search_corpus
|
from haiku.rag.capabilities._tools import CodeExecutionEntry, search_corpus
|
||||||
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.models.chunk import SearchResult
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
from haiku.rag.store.models.citation import Citation, resolve_citations
|
from haiku.rag.store.models.citation import Citation, resolve_citations
|
||||||
from haiku.rag.tools.search import build_binary_parts_from_results
|
from haiku.rag.tools.search import build_image_content_from_results
|
||||||
|
|
||||||
CITATION_GRACE_REQUESTS = 2
|
CITATION_GRACE_REQUESTS = 2
|
||||||
"""Requests calling this capability's tools that its cite tool outlives the rest by.
|
"""Requests calling this capability's tools that its cite tool outlives the rest by.
|
||||||
|
|
@ -70,47 +69,42 @@ def resolve_db_path(db_path: Path | None, config: AppConfig) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def _clear_invocation_state(state: BaseModel) -> None:
|
def _clear_invocation_state(state: BaseModel) -> None:
|
||||||
|
"""Drop the working evidence of the previous question.
|
||||||
|
|
||||||
|
Only ever called when a new question starts. A resumption keeps it: the
|
||||||
|
results belong to the question still being answered, and dropping them leaves
|
||||||
|
a later citation unable to resolve against the expanded result the model saw,
|
||||||
|
recording no provenance for it.
|
||||||
|
"""
|
||||||
for field_name in ("citations", "searches", "executions"):
|
for field_name in ("citations", "searches", "executions"):
|
||||||
value = getattr(state, field_name, None)
|
value = getattr(state, field_name, None)
|
||||||
if hasattr(value, "clear"):
|
if hasattr(value, "clear"):
|
||||||
value.clear()
|
value.clear()
|
||||||
|
|
||||||
|
|
||||||
def _compact_old_tool_returns(
|
def _is_resumption(prompt: Any, messages: list[ModelMessage]) -> bool:
|
||||||
messages: list[ModelMessage], tool_names: frozenset[str]
|
"""Whether this run continues a question rather than asking a new one.
|
||||||
) -> list[ModelMessage]:
|
|
||||||
"""Remove bulky prior-turn evidence while retaining the current turn.
|
|
||||||
|
|
||||||
Tool call and return parts remain paired; only the old return payload is
|
Two signals, either of which is enough, because getting this wrong hands the
|
||||||
replaced. This keeps provider histories valid and preserves all evidence
|
model a notice where its own evidence should be:
|
||||||
gathered since the most recent user prompt.
|
|
||||||
|
- no prompt: how pydantic-ai resumes for interruptions and suspensions.
|
||||||
|
- an unfinished tail: the history ends with a request the model has not
|
||||||
|
answered, or with a response whose tool calls have no returns yet. Deferred
|
||||||
|
tool results may arrive *with* a prompt, so the prompt alone is not enough.
|
||||||
|
|
||||||
|
A settled history ends with the previous answer, so a genuinely new question
|
||||||
|
is not mistaken for a continuation. The framework's own first-new-message
|
||||||
|
index would be better than either signal, but it is not public here.
|
||||||
"""
|
"""
|
||||||
latest_user_message = -1
|
if prompt is None:
|
||||||
for index, message in enumerate(messages):
|
return True
|
||||||
if isinstance(message, ModelRequest) and any(
|
if not messages:
|
||||||
isinstance(part, UserPromptPart) for part in message.parts
|
return False
|
||||||
):
|
last = messages[-1]
|
||||||
latest_user_message = index
|
if isinstance(last, ModelRequest):
|
||||||
|
return True
|
||||||
if latest_user_message < 0:
|
return any(isinstance(part, ToolCallPart) for part in last.parts)
|
||||||
return messages
|
|
||||||
|
|
||||||
compacted = list(messages)
|
|
||||||
for index, message in enumerate(messages[:latest_user_message]):
|
|
||||||
if not isinstance(message, ModelRequest):
|
|
||||||
continue
|
|
||||||
parts = [
|
|
||||||
replace(
|
|
||||||
part,
|
|
||||||
content="[Prior-turn RAG tool output removed; citations remain in state.]",
|
|
||||||
)
|
|
||||||
if isinstance(part, ToolReturnPart) and part.tool_name in tool_names
|
|
||||||
else part
|
|
||||||
for part in message.parts
|
|
||||||
]
|
|
||||||
if parts != message.parts:
|
|
||||||
compacted[index] = replace(message, parts=parts)
|
|
||||||
return compacted
|
|
||||||
|
|
||||||
|
|
||||||
def _called_own_tool(messages: list[ModelMessage], tool_names: frozenset[str]) -> bool:
|
def _called_own_tool(messages: list[ModelMessage], tool_names: frozenset[str]) -> bool:
|
||||||
|
|
@ -142,13 +136,38 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
search_count: int = field(default=0, repr=False)
|
search_count: int = field(default=0, repr=False)
|
||||||
request_count: int = field(default=0, repr=False)
|
request_count: int = field(default=0, repr=False)
|
||||||
grace_requests_used: int = field(default=0, repr=False)
|
grace_requests_used: int = field(default=0, repr=False)
|
||||||
|
epoch: int = field(default=0, repr=False)
|
||||||
|
|
||||||
async def for_run(self, ctx: RunContext[Any]) -> "RAGCapabilityBase[StateT]":
|
async def for_run(self, ctx: RunContext[Any]) -> "RAGCapabilityBase[StateT]":
|
||||||
|
"""Start a run's own copy, and settle which question it is answering.
|
||||||
|
|
||||||
|
A new question takes the message count as its identity, which every
|
||||||
|
participant derives identically from the same history. A resumption keeps
|
||||||
|
the identity already recorded: the question is the one in progress, and
|
||||||
|
adopting the current count would relabel it as a new one and judge its
|
||||||
|
declarations against the wrong question. A resumption with no recorded
|
||||||
|
identity is a state this design does not produce, so it is reported rather
|
||||||
|
than guessed at. With no history at all there is nothing in progress: an
|
||||||
|
absent prompt is then an instructions-only first question, which takes an
|
||||||
|
identity like any other.
|
||||||
|
"""
|
||||||
outer = getattr(ctx.deps, "state", None)
|
outer = getattr(ctx.deps, "state", None)
|
||||||
outer_state = outer if isinstance(outer, dict) else None
|
outer_state = outer if isinstance(outer, dict) else None
|
||||||
raw_state = outer_state.get(self.state_namespace) if outer_state else None
|
raw_state = outer_state.get(self.state_namespace) if outer_state else None
|
||||||
|
resuming = _is_resumption(ctx.prompt, ctx.messages)
|
||||||
|
continuing = resuming and bool(ctx.messages)
|
||||||
state = self.state_type.model_validate(raw_state or {})
|
state = self.state_type.model_validate(raw_state or {})
|
||||||
_clear_invocation_state(state)
|
record = cast(CapabilityEvidenceRecord, cast(Any, state).evidence)
|
||||||
|
if continuing and record.question is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"The {self.state_namespace} capability is resuming a question with "
|
||||||
|
"no stored question identity. Capabilities cannot be added, removed "
|
||||||
|
"or migrated while a question is unfinished, and the run's state "
|
||||||
|
"must be carried between its runs."
|
||||||
|
)
|
||||||
|
if not continuing:
|
||||||
|
_clear_invocation_state(state)
|
||||||
|
record.begin_question(len(ctx.messages))
|
||||||
run_capability = replace(
|
run_capability = replace(
|
||||||
self,
|
self,
|
||||||
state=state,
|
state=state,
|
||||||
|
|
@ -159,6 +178,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
search_count=0,
|
search_count=0,
|
||||||
request_count=0,
|
request_count=0,
|
||||||
grace_requests_used=0,
|
grace_requests_used=0,
|
||||||
|
epoch=0,
|
||||||
)
|
)
|
||||||
run_capability._sync_state()
|
run_capability._sync_state()
|
||||||
return run_capability
|
return run_capability
|
||||||
|
|
@ -171,9 +191,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
async def before_model_request(
|
async def before_model_request(
|
||||||
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
||||||
) -> ModelRequestContext:
|
) -> ModelRequestContext:
|
||||||
request_context.messages = _compact_old_tool_returns(
|
self.epoch = len(ctx.messages)
|
||||||
request_context.messages, self.tool_names
|
|
||||||
)
|
|
||||||
if instruction := self._budget_notice():
|
if instruction := self._budget_notice():
|
||||||
current_request = request_context.messages[-1]
|
current_request = request_context.messages[-1]
|
||||||
if isinstance(current_request, ModelRequest):
|
if isinstance(current_request, ModelRequest):
|
||||||
|
|
@ -217,7 +235,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
)
|
)
|
||||||
if spent := self._spent_tool_names():
|
if spent := self._spent_tool_names():
|
||||||
names = ", ".join(sorted(spent))
|
names = ", ".join(sorted(spent))
|
||||||
if remaining := sorted(self._evidence_tool_names() - spent):
|
if remaining := sorted(self.evidence_tool_names() - spent):
|
||||||
return (
|
return (
|
||||||
f"The {self.state_namespace} capability has spent its budget "
|
f"The {self.state_namespace} capability has spent its budget "
|
||||||
f"for {names}; further calls to them fail. Gather any further "
|
f"for {names}; further calls to them fail. Gather any further "
|
||||||
|
|
@ -255,8 +273,13 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
if tool.capability_id != self.id or tool.name == self._cite_tool_name
|
if tool.capability_id != self.id or tool.name == self._cite_tool_name
|
||||||
]
|
]
|
||||||
|
|
||||||
def _evidence_tool_names(self) -> set[str]:
|
def evidence_tool_names(self) -> set[str]:
|
||||||
"""Tools that can bring new evidence into the run."""
|
"""Tools that can bring new evidence into the run.
|
||||||
|
|
||||||
|
Public because compaction needs to know whose output on the wire is
|
||||||
|
evidence: a cite acknowledgement is a receipt of the model's own action and
|
||||||
|
must survive, while a code execution that reached the corpus is evidence.
|
||||||
|
"""
|
||||||
return {f"{self.state_namespace}_search"}
|
return {f"{self.state_namespace}_search"}
|
||||||
|
|
||||||
def _spent_tool_names(self) -> set[str]:
|
def _spent_tool_names(self) -> set[str]:
|
||||||
|
|
@ -306,6 +329,18 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
self.rag = rag
|
self.rag = rag
|
||||||
return self.rag
|
return self.rag
|
||||||
|
|
||||||
|
async def get_picture_bytes(self, document_id: str, self_ref: str) -> bytes | None:
|
||||||
|
"""Fetch a picture of this capability's evidence, for whoever re-attaches it.
|
||||||
|
|
||||||
|
Public because compaction rehydrates cited pictures and this capability
|
||||||
|
already holds the connection they came from; bytes are never kept in state.
|
||||||
|
"""
|
||||||
|
async with self.rag_lock:
|
||||||
|
rag = await self._ensure_rag()
|
||||||
|
return await rag.document_item_repository.get_picture_bytes(
|
||||||
|
document_id, self_ref
|
||||||
|
)
|
||||||
|
|
||||||
async def _close(self) -> None:
|
async def _close(self) -> None:
|
||||||
if self.rag is not None:
|
if self.rag is not None:
|
||||||
await self.rag.__aexit__(None, None, None)
|
await self.rag.__aexit__(None, None, None)
|
||||||
|
|
@ -326,6 +361,41 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
finally:
|
finally:
|
||||||
self._sync_state()
|
self._sync_state()
|
||||||
|
|
||||||
|
def _evidence_record(self) -> CapabilityEvidenceRecord:
|
||||||
|
assert self.state is not None
|
||||||
|
return cast(CapabilityEvidenceRecord, cast(Any, self.state).evidence)
|
||||||
|
|
||||||
|
def _note_evidence(self) -> None:
|
||||||
|
"""Record an outcome the model can ground an answer on.
|
||||||
|
|
||||||
|
Includes an empty search result and a failed execution that still printed
|
||||||
|
output: negative evidence grounds a refusal. Excludes a spent budget, which
|
||||||
|
yields nothing to ground anything on.
|
||||||
|
"""
|
||||||
|
self._evidence_record().note_evidence(self.epoch)
|
||||||
|
|
||||||
|
def _declare(self, citations: list[Citation]) -> None:
|
||||||
|
"""Record what the model cited, once the ids have resolved.
|
||||||
|
|
||||||
|
Declaring earlier would let a call naming only unresolvable ids read as a
|
||||||
|
grounded answer.
|
||||||
|
"""
|
||||||
|
state = cast(Any, self.state)
|
||||||
|
retrieved = {
|
||||||
|
result.chunk_id
|
||||||
|
for results in state.searches.values()
|
||||||
|
for result in results
|
||||||
|
if result.chunk_id
|
||||||
|
}
|
||||||
|
self._evidence_record().declare(
|
||||||
|
[
|
||||||
|
EvidenceRef(capability=self.state_namespace, chunk_id=c.chunk_id)
|
||||||
|
for c in citations
|
||||||
|
],
|
||||||
|
epoch=self.epoch,
|
||||||
|
retrieved_now=retrieved,
|
||||||
|
)
|
||||||
|
|
||||||
async def _search(self, query: str, limit: int | None) -> str | ToolReturn:
|
async def _search(self, query: str, limit: int | None) -> str | ToolReturn:
|
||||||
assert self.state is not None
|
assert self.state is not None
|
||||||
self.search_count += 1
|
self.search_count += 1
|
||||||
|
|
@ -343,7 +413,8 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
)
|
)
|
||||||
state = cast(Any, self.state)
|
state = cast(Any, self.state)
|
||||||
state.searches[query] = results
|
state.searches[query] = results
|
||||||
if self.vision and (parts := build_binary_parts_from_results(results)):
|
self._note_evidence()
|
||||||
|
if self.vision and (parts := build_image_content_from_results(results)):
|
||||||
return ToolReturn(return_value=formatted, content=parts)
|
return ToolReturn(return_value=formatted, content=parts)
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
|
|
@ -390,6 +461,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
|
||||||
"Copy chunk_ids verbatim from search results."
|
"Copy chunk_ids verbatim from search results."
|
||||||
)
|
)
|
||||||
self._register_citations(citations)
|
self._register_citations(citations)
|
||||||
|
self._declare(citations)
|
||||||
resolved = {citation.chunk_id for citation in citations}
|
resolved = {citation.chunk_id for citation in citations}
|
||||||
unresolved = [cid for cid in missing if cid not in resolved]
|
unresolved = [cid for cid in missing if cid not in resolved]
|
||||||
if unresolved:
|
if unresolved:
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from haiku.rag.capabilities._base import (
|
||||||
RAGCapabilityBase,
|
RAGCapabilityBase,
|
||||||
resolve_db_path,
|
resolve_db_path,
|
||||||
)
|
)
|
||||||
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||||
from haiku.rag.store.models.chunk import SearchResult
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
|
|
@ -29,6 +30,7 @@ class AnalysisState(BaseModel):
|
||||||
executions: list[CodeExecutionEntry] = Field(default_factory=list)
|
executions: list[CodeExecutionEntry] = Field(default_factory=list)
|
||||||
citation_index: dict[str, Citation] = Field(default_factory=dict)
|
citation_index: dict[str, Citation] = Field(default_factory=dict)
|
||||||
citations: list[str] = Field(default_factory=list)
|
citations: list[str] = Field(default_factory=list)
|
||||||
|
evidence: CapabilityEvidenceRecord = Field(default_factory=CapabilityEvidenceRecord)
|
||||||
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
|
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -85,11 +87,11 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||||
self.sandbox = None
|
self.sandbox = None
|
||||||
await super()._close()
|
await super()._close()
|
||||||
|
|
||||||
def _evidence_tool_names(self) -> set[str]:
|
def evidence_tool_names(self) -> set[str]:
|
||||||
# Searching from inside the sandbox does not count against
|
# Searching from inside the sandbox does not count against
|
||||||
# `qa.max_searches`, so code execution outlives a spent search budget
|
# `qa.max_searches`, so code execution outlives a spent search budget
|
||||||
# as a way to reach new evidence.
|
# as a way to reach new evidence.
|
||||||
return super()._evidence_tool_names() | {"analysis_execute_code"}
|
return super().evidence_tool_names() | {"analysis_execute_code"}
|
||||||
|
|
||||||
def _spent_tool_names(self) -> set[str]:
|
def _spent_tool_names(self) -> set[str]:
|
||||||
spent = super()._spent_tool_names()
|
spent = super()._spent_tool_names()
|
||||||
|
|
@ -107,6 +109,8 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||||
)
|
)
|
||||||
sandbox = await self._ensure_sandbox()
|
sandbox = await self._ensure_sandbox()
|
||||||
result = await sandbox.execute(code)
|
result = await sandbox.execute(code)
|
||||||
|
if result.success or result.stdout:
|
||||||
|
self._note_evidence()
|
||||||
if sandbox._search_results:
|
if sandbox._search_results:
|
||||||
existing = self.state.searches.get("_sandbox", [])
|
existing = self.state.searches.get("_sandbox", [])
|
||||||
seen = {item.chunk_id for item in existing}
|
seen = {item.chunk_id for item in existing}
|
||||||
|
|
|
||||||
445
haiku_rag_slim/haiku/rag/capabilities/compaction.py
Normal file
445
haiku_rag_slim/haiku/rag/capabilities/compaction.py
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from pydantic_ai import RunContext
|
||||||
|
from pydantic_ai.capabilities import AbstractCapability, WrapModelRequestHandler
|
||||||
|
from pydantic_ai.messages import (
|
||||||
|
BinaryContent,
|
||||||
|
ModelMessage,
|
||||||
|
ModelRequest,
|
||||||
|
ModelResponse,
|
||||||
|
ToolReturnPart,
|
||||||
|
UserPromptPart,
|
||||||
|
)
|
||||||
|
from pydantic_ai.models import ModelRequestContext
|
||||||
|
|
||||||
|
from haiku.rag.capabilities._base import RAGCapabilityBase
|
||||||
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
|
||||||
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
from haiku.rag.tools.search import RETRIEVED_IMAGE_TAG, decode_picture
|
||||||
|
|
||||||
|
CAPABILITY_ID = "haiku-rag-evidence-compaction"
|
||||||
|
|
||||||
|
CAPSULE_HEADER = (
|
||||||
|
"[Evidence cited earlier in this conversation, kept so later questions can "
|
||||||
|
"rely on it. Cite these chunk_ids directly when you use them.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
RECEIPT = (
|
||||||
|
"[Evidence retrieved for an earlier question, no longer shown. It does not "
|
||||||
|
"count as cited for the current question.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
ENTRY_SEPARATOR = "\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def group_label(position: int) -> str:
|
||||||
|
"""Name a group by its position among the groups, not by question number.
|
||||||
|
|
||||||
|
A question identity is a message count, so a header built from it would present
|
||||||
|
an index as a turn number, and an ordinal over the groups is not the
|
||||||
|
conversation's ordinal either whenever a question in between cited nothing. The
|
||||||
|
label claims only what it is: a grouping, newest first.
|
||||||
|
"""
|
||||||
|
return f"[Cited evidence group {position}]"
|
||||||
|
|
||||||
|
|
||||||
|
def picture_label(chunk_id: str, self_ref: str) -> str:
|
||||||
|
return (
|
||||||
|
f"Page image retrieved from the knowledge base for cited evidence "
|
||||||
|
f"[{chunk_id}] ({self_ref}). Not provided by the user. {RETRIEVED_IMAGE_TAG}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DiscoveredEvidence:
|
||||||
|
"""One evidence capability's records, as the compactor found them.
|
||||||
|
|
||||||
|
Read-only and rebuilt per request: the compactor merges these into a view and
|
||||||
|
persists nothing about evidence itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
capability: str
|
||||||
|
record: CapabilityEvidenceRecord
|
||||||
|
citations: Mapping[str, Citation]
|
||||||
|
tool_names: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetainedPicture:
|
||||||
|
"""A picture to re-attach, with the label that must accompany it.
|
||||||
|
|
||||||
|
Addressed by owner, document and reference, because a reference such as
|
||||||
|
``#/pictures/0`` repeats across documents and capabilities. The label travels
|
||||||
|
with it so it can never be emitted without its image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
capability: str
|
||||||
|
chunk_id: str
|
||||||
|
document_id: str
|
||||||
|
self_ref: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Capsule:
|
||||||
|
"""Everything the compactor would insert, and nothing about where it goes."""
|
||||||
|
|
||||||
|
text: str = ""
|
||||||
|
pictures: tuple[RetainedPicture, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Entry:
|
||||||
|
capability: str
|
||||||
|
chunk_id: str
|
||||||
|
question: int
|
||||||
|
citation: Citation
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
title = self.citation.document_title
|
||||||
|
uri = self.citation.document_uri
|
||||||
|
source = f'"{title}"' if title else uri
|
||||||
|
if title and uri and uri != title:
|
||||||
|
source = f"{source} ({uri})"
|
||||||
|
return f"[{self.chunk_id}] Source: {source}\n{self.citation.content}"
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_entries(evidence: Sequence[DiscoveredEvidence]) -> list[_Entry]:
|
||||||
|
"""Cited evidence with content, newest citing question first.
|
||||||
|
|
||||||
|
Evidence cited in several questions belongs to the most recent one, so it is
|
||||||
|
rendered once and grouped where the model last used it.
|
||||||
|
|
||||||
|
An occurrence and its canonical ``Citation`` are written by the same call, so a
|
||||||
|
cited chunk without one is not a state this design produces. Rendering the rest
|
||||||
|
regardless would quietly drop evidence an answer rested on, so it is reported.
|
||||||
|
"""
|
||||||
|
entries = []
|
||||||
|
for discovered in evidence:
|
||||||
|
for chunk_id, occurrence in discovered.record.occurrences.items():
|
||||||
|
if not occurrence.cited_in_questions:
|
||||||
|
continue
|
||||||
|
citation = discovered.citations.get(chunk_id)
|
||||||
|
if citation is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{discovered.capability} cited {chunk_id} in question(s) "
|
||||||
|
f"{occurrence.cited_in_questions} but has no citation record "
|
||||||
|
"for it, so its content cannot be retained."
|
||||||
|
)
|
||||||
|
entries.append(
|
||||||
|
_Entry(
|
||||||
|
capability=discovered.capability,
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
question=max(occurrence.cited_in_questions),
|
||||||
|
citation=citation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entries.sort(key=lambda entry: (-entry.question, entry.capability, entry.chunk_id))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def build_capsule(evidence: Sequence[DiscoveredEvidence]) -> Capsule:
|
||||||
|
"""Render every cited piece of evidence, grouped by the question that cited it.
|
||||||
|
|
||||||
|
Everything cited is kept whole and everything else is dropped. There is no
|
||||||
|
character budget: what a model can hold is the model's business, and a knob for
|
||||||
|
it would only half-rescue models that fail on long conversations regardless.
|
||||||
|
|
||||||
|
A host that needs earlier evidence pruned can compact further on top, on the wire
|
||||||
|
only. Removing or reordering the stored history breaks the message counts that
|
||||||
|
question identities and epochs are derived from, and the next record written is
|
||||||
|
refused.
|
||||||
|
|
||||||
|
Pure: no I/O and no message history, so what goes on the wire stays separable
|
||||||
|
from what it should contain. Picture bytes are fetched by the caller, which is
|
||||||
|
why a picture travels with its label rather than beside it.
|
||||||
|
"""
|
||||||
|
entries = _eligible_entries(evidence)
|
||||||
|
if not entries:
|
||||||
|
return Capsule()
|
||||||
|
|
||||||
|
lines = [CAPSULE_HEADER]
|
||||||
|
pictures: list[RetainedPicture] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
position = 0
|
||||||
|
current_question: int | None = None
|
||||||
|
for entry in entries:
|
||||||
|
if entry.question != current_question:
|
||||||
|
position += 1
|
||||||
|
current_question = entry.question
|
||||||
|
lines.append(group_label(position))
|
||||||
|
lines.append(entry.render())
|
||||||
|
for self_ref in entry.citation.picture_refs:
|
||||||
|
# Overlapping chunks cite one figure, and a provider counts it twice.
|
||||||
|
# Identity is owner plus document plus reference, so the same reference
|
||||||
|
# in another document stays a different picture.
|
||||||
|
identity = (entry.capability, entry.citation.document_id, self_ref)
|
||||||
|
if identity in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identity)
|
||||||
|
pictures.append(
|
||||||
|
RetainedPicture(
|
||||||
|
capability=entry.capability,
|
||||||
|
chunk_id=entry.chunk_id,
|
||||||
|
document_id=entry.citation.document_id,
|
||||||
|
self_ref=self_ref,
|
||||||
|
label=picture_label(entry.chunk_id, self_ref),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Capsule(text=ENTRY_SEPARATOR.join(lines), pictures=tuple(pictures))
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_our_pictures(part: UserPromptPart) -> UserPromptPart | None:
|
||||||
|
"""Drop the pictures we attached, together with the labels describing them.
|
||||||
|
|
||||||
|
Ours is a label carrying the machine tag immediately followed by an image —
|
||||||
|
both halves required. Position alone is not ownership, since several tools'
|
||||||
|
results can arrive in one request; prose alone is not either, because a user can
|
||||||
|
write any phrase, and treating one as proof removed a user's own picture along
|
||||||
|
with their text. A label is only ever dropped with its picture: left behind it
|
||||||
|
would tell the model a figure is present when it is gone.
|
||||||
|
"""
|
||||||
|
if isinstance(part.content, str):
|
||||||
|
return part
|
||||||
|
items = list(part.content)
|
||||||
|
kept: list[Any] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(items):
|
||||||
|
item = items[index]
|
||||||
|
following = items[index + 1] if index + 1 < len(items) else None
|
||||||
|
is_ours = (
|
||||||
|
isinstance(item, str)
|
||||||
|
and RETRIEVED_IMAGE_TAG in item
|
||||||
|
and isinstance(following, BinaryContent)
|
||||||
|
)
|
||||||
|
if is_ours:
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
kept.append(item)
|
||||||
|
index += 1
|
||||||
|
return replace(part, content=kept) if kept else None
|
||||||
|
|
||||||
|
|
||||||
|
def compact_history(
|
||||||
|
messages: list[ModelMessage],
|
||||||
|
*,
|
||||||
|
boundary: int,
|
||||||
|
owned_tools: frozenset[str],
|
||||||
|
capsule_text: str,
|
||||||
|
capsule_images: Sequence[str | BinaryContent] = (),
|
||||||
|
) -> list[ModelMessage]:
|
||||||
|
"""Replace earlier questions' evidence with the capsule, on a copy.
|
||||||
|
|
||||||
|
``boundary`` is how many messages existed when the current question arrived, so
|
||||||
|
everything below it belongs to an earlier one. It comes from the recorded
|
||||||
|
question identity rather than from message shape: mid-question a user-role part
|
||||||
|
is as likely to be page images or an injected notice, and reading either as the
|
||||||
|
next question strips evidence the model is still answering from.
|
||||||
|
|
||||||
|
The newest earlier return carries the capsule and every other becomes a receipt,
|
||||||
|
so exactly one capsule exists by construction. Returns are never removed, only
|
||||||
|
rewritten, which keeps each one paired with its call. Nothing outside this
|
||||||
|
capability's evidence tools is touched — not a cite acknowledgement, not another
|
||||||
|
capability's output, not a picture the user attached.
|
||||||
|
"""
|
||||||
|
if boundary <= 0:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
carrier = _newest_owned_return(messages, boundary, owned_tools)
|
||||||
|
compacted = list(messages)
|
||||||
|
for index, message in enumerate(messages[:boundary]):
|
||||||
|
if not isinstance(message, ModelRequest):
|
||||||
|
continue
|
||||||
|
parts: list[Any] = []
|
||||||
|
for position, part in enumerate(message.parts):
|
||||||
|
if isinstance(part, ToolReturnPart) and part.tool_name in owned_tools:
|
||||||
|
carries = (index, position) == carrier
|
||||||
|
body = capsule_text or RECEIPT if carries else RECEIPT
|
||||||
|
parts.append(replace(part, content=body))
|
||||||
|
elif isinstance(part, UserPromptPart):
|
||||||
|
if (kept := _strip_our_pictures(part)) is not None:
|
||||||
|
parts.append(kept)
|
||||||
|
else:
|
||||||
|
parts.append(part)
|
||||||
|
if carrier is not None and index == carrier[0] and capsule_images:
|
||||||
|
parts.append(UserPromptPart(content=list(capsule_images)))
|
||||||
|
if not parts:
|
||||||
|
# A request with no parts is not a message; whatever emptied it was not
|
||||||
|
# ours to remove after all.
|
||||||
|
continue
|
||||||
|
if parts != message.parts:
|
||||||
|
compacted[index] = replace(message, parts=parts)
|
||||||
|
return compacted
|
||||||
|
|
||||||
|
|
||||||
|
def _newest_owned_return(
|
||||||
|
messages: list[ModelMessage], boundary: int, owned_tools: frozenset[str]
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
"""Where the last of our evidence returns is, as message and part.
|
||||||
|
|
||||||
|
The part matters: a model can call search twice in one response, so one request
|
||||||
|
can hold several of our returns, and giving the capsule to each duplicates the
|
||||||
|
whole of it.
|
||||||
|
"""
|
||||||
|
for index in range(min(boundary, len(messages)) - 1, -1, -1):
|
||||||
|
message = messages[index]
|
||||||
|
if not isinstance(message, ModelRequest):
|
||||||
|
continue
|
||||||
|
for position in range(len(message.parts) - 1, -1, -1):
|
||||||
|
part = message.parts[position]
|
||||||
|
if isinstance(part, ToolReturnPart) and part.tool_name in owned_tools:
|
||||||
|
return index, position
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvidenceCompactionCapability(AbstractCapability[Any]):
|
||||||
|
"""Rewrites the history from what the evidence capabilities recorded.
|
||||||
|
|
||||||
|
Registering it is what turns compaction on: a host that leaves it out gets an
|
||||||
|
untouched transcript, which is why it has no enable flag. It reads the evidence
|
||||||
|
capabilities through the run's registry and holds no reference to any of them,
|
||||||
|
so a host running one capability, both, or neither needs no wiring change.
|
||||||
|
|
||||||
|
Registering two is rejected by pydantic-ai before the run starts, since they
|
||||||
|
would share this capability's id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
built_for: tuple[str | None, int] | None = field(default=None, repr=False)
|
||||||
|
capsule: Capsule = field(default_factory=Capsule, repr=False)
|
||||||
|
images: tuple[str | BinaryContent, ...] = field(default=(), repr=False)
|
||||||
|
|
||||||
|
async def for_run(self, ctx: RunContext[Any]) -> "EvidenceCompactionCapability":
|
||||||
|
"""Give the run its own build cache, so concurrent runs cannot share one."""
|
||||||
|
return replace(self, built_for=None, capsule=Capsule(), images=())
|
||||||
|
|
||||||
|
async def wrap_model_request(
|
||||||
|
self,
|
||||||
|
ctx: RunContext[Any],
|
||||||
|
*,
|
||||||
|
request_context: ModelRequestContext,
|
||||||
|
handler: WrapModelRequestHandler,
|
||||||
|
) -> ModelResponse:
|
||||||
|
"""Rewrite the request, never the stored history.
|
||||||
|
|
||||||
|
Deliberately not ``before_model_request``: that hook's result is assigned
|
||||||
|
back onto the run's message history, which would destroy the host's record
|
||||||
|
of what was retrieved and break the message counts that question identities
|
||||||
|
and epochs are derived from.
|
||||||
|
"""
|
||||||
|
evidence = self.discover(ctx)
|
||||||
|
boundary = max((found.record.question or 0 for found in evidence), default=0)
|
||||||
|
if boundary > 0:
|
||||||
|
await self._build_once(ctx, evidence)
|
||||||
|
request_context.messages = compact_history(
|
||||||
|
request_context.messages,
|
||||||
|
boundary=boundary,
|
||||||
|
owned_tools=frozenset().union(
|
||||||
|
*(found.tool_names for found in evidence)
|
||||||
|
),
|
||||||
|
capsule_text=self.capsule.text,
|
||||||
|
capsule_images=self.images,
|
||||||
|
)
|
||||||
|
return await handler(request_context)
|
||||||
|
|
||||||
|
async def _build_once(
|
||||||
|
self, ctx: RunContext[Any], evidence: Sequence[DiscoveredEvidence]
|
||||||
|
) -> None:
|
||||||
|
"""Build the capsule once per model request, however often the hook runs.
|
||||||
|
|
||||||
|
Keyed on the run and its step rather than persisted: a stored key would
|
||||||
|
freeze one question's capsule across the next.
|
||||||
|
"""
|
||||||
|
key = (ctx.run_id, ctx.run_step)
|
||||||
|
if key == self.built_for:
|
||||||
|
return
|
||||||
|
self.capsule = build_capsule(evidence)
|
||||||
|
self.images = await self._rehydrate(ctx)
|
||||||
|
self.built_for = key
|
||||||
|
|
||||||
|
async def _rehydrate(self, ctx: RunContext[Any]) -> tuple[str | BinaryContent, ...]:
|
||||||
|
"""Fetch the cited pictures through the capability that retrieved them.
|
||||||
|
|
||||||
|
Bytes are never stored in state, and the owner already holds an open
|
||||||
|
connection. A picture that cannot be fetched, for any reason, or that will
|
||||||
|
not decode, is emitted with neither its image nor its label: a label can
|
||||||
|
never outlive what it describes, and a figure the model has already been
|
||||||
|
given in text is not worth failing a question over.
|
||||||
|
"""
|
||||||
|
owners = {
|
||||||
|
capability.state_namespace: capability
|
||||||
|
for capability in ctx.capabilities.values()
|
||||||
|
if isinstance(capability, RAGCapabilityBase)
|
||||||
|
}
|
||||||
|
content: list[str | BinaryContent] = []
|
||||||
|
for retained in self.capsule.pictures:
|
||||||
|
# Indexed, not looked up defensively: the capsule was built from these
|
||||||
|
# same capabilities in this same call, so a missing owner is a broken
|
||||||
|
# invariant rather than a picture to skip.
|
||||||
|
owner = owners[retained.capability]
|
||||||
|
try:
|
||||||
|
data = await owner.get_picture_bytes(
|
||||||
|
retained.document_id, retained.self_ref
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# A read that fails costs this picture, not the answer.
|
||||||
|
continue
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
picture = decode_picture(data, retained.self_ref)
|
||||||
|
if picture is None:
|
||||||
|
continue
|
||||||
|
content.append(retained.label)
|
||||||
|
content.append(picture)
|
||||||
|
return tuple(content)
|
||||||
|
|
||||||
|
def discover(self, ctx: RunContext[Any]) -> list[DiscoveredEvidence]:
|
||||||
|
"""Read what each evidence capability recorded, without writing anything.
|
||||||
|
|
||||||
|
The registry holds the per-run instances, which are the ones carrying
|
||||||
|
state; the registered objects never do. That includes a deferred capability
|
||||||
|
the model has not loaded, whose record is simply empty.
|
||||||
|
"""
|
||||||
|
discovered = []
|
||||||
|
for capability in ctx.capabilities.values():
|
||||||
|
if not isinstance(capability, RAGCapabilityBase):
|
||||||
|
continue
|
||||||
|
state = capability.state
|
||||||
|
discovered.append(
|
||||||
|
DiscoveredEvidence(
|
||||||
|
capability=capability.state_namespace,
|
||||||
|
record=cast(CapabilityEvidenceRecord, cast(Any, state).evidence),
|
||||||
|
citations=cast(Any, state).citation_index,
|
||||||
|
tool_names=frozenset(capability.evidence_tool_names()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(discovered, key=lambda evidence: evidence.capability)
|
||||||
|
|
||||||
|
|
||||||
|
def create_capability() -> EvidenceCompactionCapability:
|
||||||
|
"""Create the capability that compacts history from recorded evidence."""
|
||||||
|
return EvidenceCompactionCapability(
|
||||||
|
id=CAPABILITY_ID,
|
||||||
|
description=(
|
||||||
|
"Replaces earlier questions' evidence on the wire with a capsule of "
|
||||||
|
"what was cited."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ID",
|
||||||
|
"CAPSULE_HEADER",
|
||||||
|
"RECEIPT",
|
||||||
|
"Capsule",
|
||||||
|
"DiscoveredEvidence",
|
||||||
|
"EvidenceCompactionCapability",
|
||||||
|
"RetainedPicture",
|
||||||
|
"build_capsule",
|
||||||
|
"compact_history",
|
||||||
|
"create_capability",
|
||||||
|
"group_label",
|
||||||
|
"picture_label",
|
||||||
|
]
|
||||||
213
haiku_rag_slim/haiku/rag/capabilities/ledger.py
Normal file
213
haiku_rag_slim/haiku/rag/capabilities/ledger.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
CitationStatus = Literal["missing", "grounded", "ungrounded"]
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceRef(BaseModel):
|
||||||
|
"""One piece of evidence, identified by its owner as well as its chunk.
|
||||||
|
|
||||||
|
A chunk id alone is not an identity: the same id can be reported by more than
|
||||||
|
one capability, and ownership is what tells compaction whose output it may
|
||||||
|
touch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
capability: str
|
||||||
|
chunk_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceOccurrence(BaseModel):
|
||||||
|
"""Which questions retrieved a piece of evidence, and which cited it."""
|
||||||
|
|
||||||
|
capability: str
|
||||||
|
chunk_id: str
|
||||||
|
retrieved_in_questions: list[int] = Field(default_factory=list)
|
||||||
|
cited_in_questions: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CitationDeclaration(BaseModel):
|
||||||
|
"""What a question declared as its grounding, and when.
|
||||||
|
|
||||||
|
Bound to a question *and* an epoch: the epoch outlives a question, so matching
|
||||||
|
it alone would let a question that gathered no evidence inherit the previous
|
||||||
|
declaration and read as compliant having declared nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
question: int
|
||||||
|
epoch: int
|
||||||
|
refs: list[EvidenceRef] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityEvidenceRecord(BaseModel):
|
||||||
|
"""What one evidence capability wrote, in its own state namespace.
|
||||||
|
|
||||||
|
Holds no content: ``Citation`` in ``citation_index`` is the canonical record
|
||||||
|
and already persists content, document id and picture refs. This is the index
|
||||||
|
over it that compaction needs and the transcript cannot provide.
|
||||||
|
|
||||||
|
Single-writer by construction. A record shared between capabilities would be
|
||||||
|
overwritten by whichever of them synced its state last; merging happens in the
|
||||||
|
transient views built by ``citation_status`` and the optional capabilities.
|
||||||
|
|
||||||
|
``question`` is the number of messages that existed when the question arrived,
|
||||||
|
and ``epoch`` the number when an outcome occurred. Both are derived from the
|
||||||
|
conversation rather than counted locally, so every participant computes the
|
||||||
|
same values without sharing a counter. ``question`` is unset until a run
|
||||||
|
establishes it, so a record a host merely created is distinguishable from one
|
||||||
|
that has been through a question.
|
||||||
|
"""
|
||||||
|
|
||||||
|
occurrences: dict[str, EvidenceOccurrence] = Field(default_factory=dict)
|
||||||
|
question: int | None = None
|
||||||
|
latest_evidence_epoch: int = 0
|
||||||
|
declaration: CitationDeclaration | None = None
|
||||||
|
|
||||||
|
def _reject_regression(self, count: int, what: str) -> None:
|
||||||
|
"""Refuse a message count below one already recorded in this question.
|
||||||
|
|
||||||
|
Identities and epochs are both message counts, and every comparison
|
||||||
|
between them assumes the conversation only grows while a question is
|
||||||
|
being answered. One capability truncating or reordering the history
|
||||||
|
breaks that, and each way of recording it has to refuse the same way: an
|
||||||
|
unchecked evidence outcome freezes every later declaration as stale, while
|
||||||
|
an unchecked declaration replaces a newer one with an older one and
|
||||||
|
revives the answer it grounded.
|
||||||
|
"""
|
||||||
|
recorded = max(
|
||||||
|
self.question or 0,
|
||||||
|
self.latest_evidence_epoch,
|
||||||
|
self.declaration.epoch if self.declaration else 0,
|
||||||
|
)
|
||||||
|
if count < recorded:
|
||||||
|
raise ValueError(
|
||||||
|
f"{what} at message count {count} is behind {recorded}, which is "
|
||||||
|
"already recorded: message history must be append-only for "
|
||||||
|
"question identities and epochs to hold."
|
||||||
|
)
|
||||||
|
|
||||||
|
def begin_question(self, identity: int) -> None:
|
||||||
|
"""Take the identity of a question that has just arrived.
|
||||||
|
|
||||||
|
The evidence epoch and the declaration describe the question that has
|
||||||
|
just ended, and outlive their meaning the moment the next one begins:
|
||||||
|
kept, they let the last question's citations ground this one, and hold a
|
||||||
|
horizon this question's own declarations cannot pass. Clearing them
|
||||||
|
confines those comparisons to one question, so a host whose stored
|
||||||
|
history shifted between two of them is answered rather than refused.
|
||||||
|
|
||||||
|
Identities themselves must still separate one question from the next.
|
||||||
|
Occurrences outlive the question that recorded them and carry the
|
||||||
|
identities that cited them, which compaction groups and orders the capsule
|
||||||
|
by: a reused identity merges two questions into one group, and a lower one
|
||||||
|
renders later evidence as though it were cited earlier.
|
||||||
|
"""
|
||||||
|
if self.question is not None and identity <= self.question:
|
||||||
|
raise ValueError(
|
||||||
|
f"A question at message count {identity} is not past question "
|
||||||
|
f"{self.question}, which is already answered: identities separate "
|
||||||
|
"one question from the next and are compared as recency."
|
||||||
|
)
|
||||||
|
self.question = identity
|
||||||
|
self.latest_evidence_epoch = 0
|
||||||
|
self.declaration = None
|
||||||
|
|
||||||
|
def note_evidence(self, epoch: int) -> None:
|
||||||
|
"""Record that the model has seen an evidence outcome.
|
||||||
|
|
||||||
|
Called for anything an answer could rest on, including a search that
|
||||||
|
returned nothing and a failed execution that still printed output — a
|
||||||
|
fruitless search grounds a refusal. Not called for a failure that yields
|
||||||
|
no evidence at all, such as an exhausted budget.
|
||||||
|
"""
|
||||||
|
self._reject_regression(epoch, "Evidence")
|
||||||
|
self.latest_evidence_epoch = epoch
|
||||||
|
|
||||||
|
def declare(
|
||||||
|
self,
|
||||||
|
refs: list[EvidenceRef],
|
||||||
|
*,
|
||||||
|
epoch: int,
|
||||||
|
retrieved_now: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record validated citations for the current question.
|
||||||
|
|
||||||
|
Repeated calls at the same epoch merge, so citing again cannot narrow what
|
||||||
|
was already declared: an empty call after a grounded one leaves it
|
||||||
|
grounded. A call at a later epoch declares afresh, because evidence the
|
||||||
|
model saw in between may be what it is now citing.
|
||||||
|
"""
|
||||||
|
if self.question is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Citations cannot be declared before a run establishes the "
|
||||||
|
"question identity."
|
||||||
|
)
|
||||||
|
self._reject_regression(epoch, "A declaration")
|
||||||
|
current = self.declaration
|
||||||
|
if current is not None and (current.question, current.epoch) == (
|
||||||
|
self.question,
|
||||||
|
epoch,
|
||||||
|
):
|
||||||
|
known = {(ref.capability, ref.chunk_id) for ref in current.refs}
|
||||||
|
current.refs.extend(
|
||||||
|
ref for ref in refs if (ref.capability, ref.chunk_id) not in known
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.declaration = CitationDeclaration(
|
||||||
|
question=self.question, epoch=epoch, refs=list(refs)
|
||||||
|
)
|
||||||
|
|
||||||
|
for ref in refs:
|
||||||
|
occurrence = self.occurrences.setdefault(
|
||||||
|
ref.chunk_id,
|
||||||
|
EvidenceOccurrence(capability=ref.capability, chunk_id=ref.chunk_id),
|
||||||
|
)
|
||||||
|
if self.question not in occurrence.cited_in_questions:
|
||||||
|
occurrence.cited_in_questions.append(self.question)
|
||||||
|
if (
|
||||||
|
retrieved_now
|
||||||
|
and ref.chunk_id in retrieved_now
|
||||||
|
and self.question not in occurrence.retrieved_in_questions
|
||||||
|
):
|
||||||
|
occurrence.retrieved_in_questions.append(self.question)
|
||||||
|
|
||||||
|
|
||||||
|
def citation_status(
|
||||||
|
records: Iterable[CapabilityEvidenceRecord], *, question: int
|
||||||
|
) -> CitationStatus:
|
||||||
|
"""Derived, never stored, so refs and status cannot contradict.
|
||||||
|
|
||||||
|
A declaration is current only for the question it was made in, and only if it
|
||||||
|
followed the newest evidence outcome of *every* capability: a question where
|
||||||
|
one capability cited and another then searched without citing is not grounded.
|
||||||
|
Strictly later, since a citation made in the same request as an outcome cannot
|
||||||
|
have read it.
|
||||||
|
|
||||||
|
A grounding *violation* is not one of these: that is an enforcement outcome
|
||||||
|
recorded by the policy capability, not something the model declared.
|
||||||
|
"""
|
||||||
|
records = list(records)
|
||||||
|
horizon = max((record.latest_evidence_epoch for record in records), default=0)
|
||||||
|
current = [
|
||||||
|
record.declaration
|
||||||
|
for record in records
|
||||||
|
if record.declaration is not None
|
||||||
|
and record.declaration.question == question
|
||||||
|
and record.declaration.epoch > horizon
|
||||||
|
]
|
||||||
|
if not current:
|
||||||
|
return "missing"
|
||||||
|
return (
|
||||||
|
"grounded" if any(declaration.refs for declaration in current) else "ungrounded"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CapabilityEvidenceRecord",
|
||||||
|
"CitationDeclaration",
|
||||||
|
"CitationStatus",
|
||||||
|
"EvidenceOccurrence",
|
||||||
|
"EvidenceRef",
|
||||||
|
"citation_status",
|
||||||
|
]
|
||||||
|
|
@ -12,6 +12,7 @@ from haiku.rag.capabilities._base import (
|
||||||
RAGCapabilityBase,
|
RAGCapabilityBase,
|
||||||
resolve_db_path,
|
resolve_db_path,
|
||||||
)
|
)
|
||||||
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.models.chunk import SearchResult
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
from haiku.rag.store.models.citation import Citation
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
|
@ -33,6 +34,7 @@ _instructions_path = Path(__file__).parent / "instructions" / "rag.md"
|
||||||
class RAGState(BaseModel):
|
class RAGState(BaseModel):
|
||||||
citation_index: dict[str, Citation] = Field(default_factory=dict)
|
citation_index: dict[str, Citation] = Field(default_factory=dict)
|
||||||
citations: list[str] = Field(default_factory=list)
|
citations: list[str] = Field(default_factory=list)
|
||||||
|
evidence: CapabilityEvidenceRecord = Field(default_factory=CapabilityEvidenceRecord)
|
||||||
document_filter: str | None = None
|
document_filter: str | None = None
|
||||||
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
|
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable, Sequence
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
@ -25,6 +26,7 @@ from textual.worker import Worker
|
||||||
|
|
||||||
from haiku.rag.capabilities._base import RAGCapabilityBase
|
from haiku.rag.capabilities._base import RAGCapabilityBase
|
||||||
from haiku.rag.capabilities.analysis import AnalysisState
|
from haiku.rag.capabilities.analysis import AnalysisState
|
||||||
|
from haiku.rag.capabilities.compaction import create_capability as create_compaction
|
||||||
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGState
|
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGState
|
||||||
from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget
|
from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget
|
||||||
from haiku.rag.chat.widgets.image_select import ImageAdded
|
from haiku.rag.chat.widgets.image_select import ImageAdded
|
||||||
|
|
@ -153,7 +155,9 @@ class ChatApp(App):
|
||||||
self._model,
|
self._model,
|
||||||
deps_type=ChatDeps,
|
deps_type=ChatDeps,
|
||||||
instructions=AGENT_PREAMBLE,
|
instructions=AGENT_PREAMBLE,
|
||||||
capabilities=self._capabilities,
|
# A chat is multi-turn by definition, so earlier questions are reduced
|
||||||
|
# to the evidence they cited rather than carried whole.
|
||||||
|
capabilities=[*self._capabilities, create_compaction()],
|
||||||
)
|
)
|
||||||
self._state = {}
|
self._state = {}
|
||||||
for capability in self._capabilities:
|
for capability in self._capabilities:
|
||||||
|
|
@ -205,7 +209,12 @@ class ChatApp(App):
|
||||||
await chat_history.show_thinking()
|
await chat_history.show_thinking()
|
||||||
|
|
||||||
message = None
|
message = None
|
||||||
deps = ChatDeps(state=self._state)
|
# The run gets a copy: state and message history have to advance together.
|
||||||
|
# A cancelled or failed run discards its messages, and state that advanced
|
||||||
|
# anyway would leave the next question deriving its identity from a shorter
|
||||||
|
# history than the evidence already recorded — refused as non-append-only,
|
||||||
|
# with the conversation stuck until it is cleared.
|
||||||
|
deps = ChatDeps(state=deepcopy(self._state))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._agent.run_stream_events(
|
async with self._agent.run_stream_events(
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ class SearchResult(BaseModel):
|
||||||
parts.append(f"Type: {primary_label}")
|
parts.append(f"Type: {primary_label}")
|
||||||
|
|
||||||
# Surface picture captions when present. Order matches the binary
|
# Surface picture captions when present. Order matches the binary
|
||||||
# attachments emitted by build_binary_parts_from_results, so the model
|
# attachments emitted by build_image_content_from_results, so the model
|
||||||
# can correlate caption ↔ attached image by position (BinaryContent
|
# can correlate caption ↔ attached image by position (BinaryContent
|
||||||
# identifiers don't survive serialization to the OpenAI vision API).
|
# identifiers don't survive serialization to the OpenAI vision API).
|
||||||
if self.picture_captions:
|
if self.picture_captions:
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,35 @@ from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.models import SearchResult
|
from haiku.rag.store.models import SearchResult
|
||||||
from haiku.rag.tools.context import RAGDeps
|
from haiku.rag.tools.context import RAGDeps
|
||||||
|
|
||||||
|
RETRIEVED_IMAGE_TAG = "[haiku.rag/retrieved-image]"
|
||||||
|
"""Tag every label we attach to a retrieved picture ends with.
|
||||||
|
|
||||||
def build_binary_parts_from_results(
|
Identifies our own pictures on the wire without inferring ownership from position,
|
||||||
|
which is wrong as soon as two tools' results arrive in one request. Deliberately not
|
||||||
|
a phrase: a user writing "retrieved from the knowledge base for my report" above
|
||||||
|
their own picture had it removed, along with their text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def decode_picture(data: bytes, self_ref: str) -> BinaryContent | None:
|
||||||
|
"""Wrap picture bytes for the wire, or return nothing if they will not decode.
|
||||||
|
|
||||||
|
The model adapter renders one vision placeholder per ``BinaryContent``, so
|
||||||
|
emitting one for an image the server cannot decode leaves the processor with an
|
||||||
|
off-by-one count.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(BytesIO(data)) as image:
|
||||||
|
image.verify()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return BinaryContent(data=data, media_type="image/png", identifier=self_ref)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image_content_from_results(
|
||||||
results: list[SearchResult],
|
results: list[SearchResult],
|
||||||
) -> list[BinaryContent]:
|
) -> list[str | BinaryContent]:
|
||||||
"""Decode and validate picture bytes attached to search results.
|
"""Decode and validate picture bytes attached to search results, labelled.
|
||||||
|
|
||||||
Dedup keyed on ``(document_id, self_ref)`` so the same picture in
|
Dedup keyed on ``(document_id, self_ref)`` so the same picture in
|
||||||
different chunks is sent once. Pictures that fail
|
different chunks is sent once. Pictures that fail
|
||||||
|
|
@ -22,8 +46,18 @@ def build_binary_parts_from_results(
|
||||||
vision placeholder per ``BinaryContent``, so emitting one for an
|
vision placeholder per ``BinaryContent``, so emitting one for an
|
||||||
image the server can't decode leaves the processor with an
|
image the server can't decode leaves the processor with an
|
||||||
off-by-one count.
|
off-by-one count.
|
||||||
|
|
||||||
|
Every picture is preceded by a line naming the result it belongs to.
|
||||||
|
``ToolReturn.content`` reaches the model as a user-role message, so
|
||||||
|
retrieved pictures are otherwise indistinguishable from ones the user
|
||||||
|
attached, and models narrate them as part of the question: unlabelled,
|
||||||
|
gemma4-26b answered about a figure from an unrelated document, and with a
|
||||||
|
single note ahead of the batch it still called them "images in the prompt".
|
||||||
|
The label also names the chunk to cite for a figure, which
|
||||||
|
``BinaryContent.identifier`` cannot do — it does not survive serialization
|
||||||
|
to the vision API.
|
||||||
"""
|
"""
|
||||||
parts: list[BinaryContent] = []
|
collected: list[tuple[str | None, str, BinaryContent]] = []
|
||||||
seen: set[tuple[str | None, str]] = set()
|
seen: set[tuple[str | None, str]] = set()
|
||||||
for result in results:
|
for result in results:
|
||||||
if not result.image_data:
|
if not result.image_data:
|
||||||
|
|
@ -32,21 +66,22 @@ def build_binary_parts_from_results(
|
||||||
key = (result.document_id, self_ref)
|
key = (result.document_id, self_ref)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
data = base64.b64decode(b64)
|
picture = decode_picture(base64.b64decode(b64), self_ref)
|
||||||
try:
|
if picture is None:
|
||||||
with Image.open(BytesIO(data)) as img:
|
|
||||||
img.verify()
|
|
||||||
except Exception:
|
|
||||||
continue
|
continue
|
||||||
parts.append(
|
collected.append((result.chunk_id, self_ref, picture))
|
||||||
BinaryContent(
|
|
||||||
data=data,
|
|
||||||
media_type="image/png",
|
|
||||||
identifier=self_ref,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
return parts
|
|
||||||
|
content: list[str | BinaryContent] = []
|
||||||
|
total = len(collected)
|
||||||
|
for position, (chunk_id, self_ref, picture) in enumerate(collected, 1):
|
||||||
|
content.append(
|
||||||
|
f"Page image {position} of {total}, retrieved from the knowledge base "
|
||||||
|
f"for search result [{chunk_id}] ({self_ref}). "
|
||||||
|
f"Not provided by the user. {RETRIEVED_IMAGE_TAG}"
|
||||||
|
)
|
||||||
|
content.append(picture)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def create_search_toolset(
|
def create_search_toolset(
|
||||||
|
|
@ -134,9 +169,9 @@ def create_search_toolset(
|
||||||
if not config.qa.model.vision:
|
if not config.qa.model.vision:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
binary_parts = build_binary_parts_from_results(results_list)
|
image_content = build_image_content_from_results(results_list)
|
||||||
if binary_parts:
|
if image_content:
|
||||||
return ToolReturn(return_value=text, content=binary_parts)
|
return ToolReturn(return_value=text, content=image_content)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
toolset: FunctionToolset[RAGDeps] = FunctionToolset()
|
toolset: FunctionToolset[RAGDeps] = FunctionToolset()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from typing import Any, cast
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic_ai import Agent, ModelRetry, RunContext, ToolFailed
|
from pydantic_ai import Agent, DeferredToolResults, ModelRetry, RunContext, ToolFailed
|
||||||
from pydantic_ai.messages import (
|
from pydantic_ai.messages import (
|
||||||
ModelRequest,
|
ModelRequest,
|
||||||
ModelResponse,
|
ModelResponse,
|
||||||
|
|
@ -20,10 +20,13 @@ from pydantic_ai.usage import RunUsage
|
||||||
from haiku.rag.capabilities._base import (
|
from haiku.rag.capabilities._base import (
|
||||||
CITATION_GRACE_REQUESTS,
|
CITATION_GRACE_REQUESTS,
|
||||||
_called_own_tool,
|
_called_own_tool,
|
||||||
_compact_old_tool_returns,
|
|
||||||
)
|
)
|
||||||
from haiku.rag.capabilities.analysis import AnalysisCapability, AnalysisState
|
from haiku.rag.capabilities.analysis import AnalysisCapability, AnalysisState
|
||||||
from haiku.rag.capabilities.analysis import create_capability as create_analysis
|
from haiku.rag.capabilities.analysis import create_capability as create_analysis
|
||||||
|
from haiku.rag.capabilities.ledger import (
|
||||||
|
CapabilityEvidenceRecord,
|
||||||
|
citation_status,
|
||||||
|
)
|
||||||
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGCapability, RAGState
|
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGCapability, RAGState
|
||||||
from haiku.rag.capabilities.rag import create_capability as create_rag
|
from haiku.rag.capabilities.rag import create_capability as create_rag
|
||||||
from haiku.rag.config.models import AppConfig, PromptsConfig
|
from haiku.rag.config.models import AppConfig, PromptsConfig
|
||||||
|
|
@ -308,7 +311,7 @@ async def test_search_and_empty_citation_limits(temp_db_path):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db_path):
|
async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db_path):
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
capability.state = RAGState()
|
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
client.get_chunk_by_id.side_effect = [
|
client.get_chunk_by_id.side_effect = [
|
||||||
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
||||||
|
|
@ -334,7 +337,7 @@ async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cite_reports_unresolved_ids_on_partial_success(temp_db_path):
|
async def test_cite_reports_unresolved_ids_on_partial_success(temp_db_path):
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
capability.state = RAGState()
|
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
client.get_chunk_by_id.side_effect = [
|
client.get_chunk_by_id.side_effect = [
|
||||||
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
||||||
|
|
@ -364,6 +367,7 @@ async def test_cite_repairs_chunk_ids_damaged_in_transcription(temp_db_path):
|
||||||
unrelated = "9c2cd07e-5a3f-45a6-968d-cbd6f06ab57b"
|
unrelated = "9c2cd07e-5a3f-45a6-968d-cbd6f06ab57b"
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
capability.state = RAGState(
|
capability.state = RAGState(
|
||||||
|
evidence=CapabilityEvidenceRecord(question=0),
|
||||||
searches={
|
searches={
|
||||||
"q": [
|
"q": [
|
||||||
SearchResult(
|
SearchResult(
|
||||||
|
|
@ -374,7 +378,7 @@ async def test_cite_repairs_chunk_ids_damaged_in_transcription(temp_db_path):
|
||||||
document_uri="test://document",
|
document_uri="test://document",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
client.get_chunk_by_id.return_value = None
|
client.get_chunk_by_id.return_value = None
|
||||||
|
|
@ -461,6 +465,58 @@ async def test_analysis_execution_limit_fails_the_tool(temp_db_path):
|
||||||
await capability._execute_code("print('done')")
|
await capability._execute_code("print('done')")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_spent_execution_budget_is_not_evidence(temp_db_path):
|
||||||
|
"""Nothing was produced to ground an answer on, so nothing is recorded."""
|
||||||
|
config = AppConfig()
|
||||||
|
config.analysis.max_executions = 0
|
||||||
|
capability = create_analysis(db_path=temp_db_path, config=config)
|
||||||
|
capability.state = AnalysisState()
|
||||||
|
capability.epoch = 5
|
||||||
|
|
||||||
|
with pytest.raises(ToolFailed):
|
||||||
|
await capability._execute_code("print('done')")
|
||||||
|
|
||||||
|
assert capability.state.evidence.latest_evidence_epoch == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("success", "stdout", "expected_epoch"),
|
||||||
|
[
|
||||||
|
pytest.param(True, "42", 5, id="succeeded"),
|
||||||
|
pytest.param(True, "", 5, id="succeeded without output"),
|
||||||
|
pytest.param(False, "42", 5, id="failed after printing"),
|
||||||
|
pytest.param(False, "", 0, id="failed without printing"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_a_code_execution_the_model_can_read_is_evidence(
|
||||||
|
temp_db_path, success, stdout, expected_epoch
|
||||||
|
):
|
||||||
|
"""A raised error with nothing printed grounds nothing, so it is not evidence.
|
||||||
|
|
||||||
|
A failure that printed first does ground an answer, and so does a successful
|
||||||
|
run whose outcome is that it printed nothing.
|
||||||
|
"""
|
||||||
|
capability = create_analysis(db_path=temp_db_path, config=AppConfig())
|
||||||
|
capability.state = AnalysisState(evidence=CapabilityEvidenceRecord(question=0))
|
||||||
|
capability.epoch = 5
|
||||||
|
sandbox = AsyncMock(spec=Sandbox)
|
||||||
|
sandbox._search_results = []
|
||||||
|
sandbox.execute.return_value = SandboxResult(
|
||||||
|
stdout=stdout, stderr="" if success else "boom", success=success
|
||||||
|
)
|
||||||
|
capability.sandbox = sandbox
|
||||||
|
|
||||||
|
if success:
|
||||||
|
await capability._execute_code("print(42)")
|
||||||
|
else:
|
||||||
|
with pytest.raises(ToolFailed):
|
||||||
|
await capability._execute_code("print(42)")
|
||||||
|
|
||||||
|
assert capability.state.evidence.latest_evidence_epoch == expected_epoch
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_spent_search_budget_is_announced_but_keeps_the_tool(rag_db):
|
async def test_spent_search_budget_is_announced_but_keeps_the_tool(rag_db):
|
||||||
"""A spent budget is announced; the tool stays declared to avoid a dead run.
|
"""A spent budget is announced; the tool stays declared to avoid a dead run.
|
||||||
|
|
@ -555,7 +611,7 @@ async def test_spent_search_notice_points_at_code_while_it_has_budget(temp_db_pa
|
||||||
notice = capability._budget_notice()
|
notice = capability._budget_notice()
|
||||||
assert notice is not None
|
assert notice is not None
|
||||||
assert "analysis_execute_code" in notice
|
assert "analysis_execute_code" in notice
|
||||||
assert capability._evidence_tool_names() <= capability._spent_tool_names()
|
assert capability.evidence_tool_names() <= capability._spent_tool_names()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -570,7 +626,7 @@ async def test_spent_search_notice_tells_rag_to_answer(temp_db_path):
|
||||||
|
|
||||||
assert notice is not None
|
assert notice is not None
|
||||||
assert "rag_search" in notice
|
assert "rag_search" in notice
|
||||||
assert capability._evidence_tool_names() == {"rag_search"}
|
assert capability.evidence_tool_names() == {"rag_search"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -746,7 +802,9 @@ async def test_native_agent_composition_initializes_host_state(temp_db_path):
|
||||||
result = await agent.run("Hello", deps=deps)
|
result = await agent.run("Hello", deps=deps)
|
||||||
|
|
||||||
assert result.output == "success (no tool calls)"
|
assert result.output == "success (no tool calls)"
|
||||||
assert deps.state["rag"] == RAGState().model_dump(mode="json")
|
assert deps.state["rag"] == RAGState(
|
||||||
|
evidence=CapabilityEvidenceRecord(question=0)
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -787,41 +845,400 @@ async def test_deferred_capability_loads_native_tools(temp_db_path):
|
||||||
assert "rag_search" in loaded_payloads[0]
|
assert "rag_search" in loaded_payloads[0]
|
||||||
|
|
||||||
|
|
||||||
def test_prior_turn_tool_results_are_compacted_but_current_evidence_is_kept():
|
def _resuming_deps() -> Deps:
|
||||||
messages = [
|
"""State as a resumption always finds it: the question already identified.
|
||||||
ModelRequest(parts=[UserPromptPart("old question")]),
|
|
||||||
ModelResponse(parts=[ToolCallPart("rag_search", {}, "old-call")]),
|
A run that resumes has been through ``for_run`` before, so the identity of the
|
||||||
|
question in progress is stored. Fabricating the history without it is a state
|
||||||
|
the design does not produce, and is rejected rather than guessed at.
|
||||||
|
"""
|
||||||
|
return Deps(
|
||||||
|
state={
|
||||||
|
"rag": RAGState(evidence=CapabilityEvidenceRecord(question=0)).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
ModelRequest(
|
||||||
parts=[ToolReturnPart("rag_search", "large old evidence", "old-call")]
|
parts=[ToolReturnPart("rag_search", "EVIDENCE FOR THE LIVE TURN", "call-1")]
|
||||||
),
|
|
||||||
ModelRequest(parts=[UserPromptPart("current question")]),
|
|
||||||
ModelResponse(parts=[ToolCallPart("rag_search", {}, "current-call")]),
|
|
||||||
ModelRequest(
|
|
||||||
parts=[ToolReturnPart("rag_search", "current evidence", "current-call")]
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
compacted = _compact_old_tool_returns(messages, frozenset({"rag_search"}))
|
|
||||||
|
|
||||||
old_return = compacted[2].parts[0]
|
def _record(deps: Deps, namespace: str) -> CapabilityEvidenceRecord:
|
||||||
current_return = compacted[5].parts[0]
|
return CapabilityEvidenceRecord.model_validate(deps.state[namespace]["evidence"])
|
||||||
assert isinstance(old_return, ToolReturnPart)
|
|
||||||
assert "removed" in str(old_return.content)
|
|
||||||
assert isinstance(current_return, ToolReturnPart)
|
|
||||||
assert current_return.content == "current evidence"
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_results_are_unchanged_when_history_has_no_user_prompt():
|
async def _stub_search(self, query: str, _limit: int | None) -> str:
|
||||||
messages = [
|
"""Record a result the way the real search does, so citing resolves."""
|
||||||
ModelResponse(parts=[ToolCallPart("rag_search", {}, "current-call")]),
|
cast(Any, self.state).searches[query] = [
|
||||||
ModelRequest(
|
SearchResult(content="evidence", score=1.0, chunk_id="chunk-1")
|
||||||
parts=[ToolReturnPart("rag_search", "current evidence", "current-call")]
|
]
|
||||||
),
|
self._note_evidence()
|
||||||
|
return "EVIDENCE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_question_takes_its_own_identity_and_both_capabilities_agree(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
"""Identity is derived from the conversation, so no counter is shared."""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
analysis = create_analysis(
|
||||||
|
db_path=temp_db_path, config=AppConfig(), defer_loading=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=[TextPart("answer")])
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag, analysis])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
first = await agent.run("first question", deps=deps)
|
||||||
|
first_identity = _record(deps, "rag").question
|
||||||
|
await agent.run("second question", deps=deps, message_history=first.all_messages())
|
||||||
|
second_identity = _record(deps, "rag").question
|
||||||
|
|
||||||
|
assert first_identity == 0
|
||||||
|
assert second_identity is not None and second_identity > 0
|
||||||
|
assert _record(deps, "analysis").question == second_identity
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_resumption_keeps_the_identity_of_the_question_in_progress(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=[TextPart("answer")])
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps(
|
||||||
|
state={
|
||||||
|
"rag": RAGState(evidence=CapabilityEvidenceRecord(question=7)).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
history = [
|
||||||
|
*_in_flight_history(),
|
||||||
|
ModelResponse(parts=[ToolCallPart("external_tool", {}, "call-2")]),
|
||||||
]
|
]
|
||||||
|
|
||||||
compacted = _compact_old_tool_returns(messages, frozenset({"rag_search"}))
|
await agent.run(
|
||||||
|
"carry on",
|
||||||
|
deferred_tool_results=DeferredToolResults(calls={"call-2": "external result"}),
|
||||||
|
message_history=history,
|
||||||
|
deps=deps,
|
||||||
|
)
|
||||||
|
|
||||||
assert compacted is messages
|
assert _record(deps, "rag").question == 7
|
||||||
current_return = compacted[1].parts[0]
|
|
||||||
assert isinstance(current_return, ToolReturnPart)
|
|
||||||
assert current_return.content == "current evidence"
|
@pytest.mark.asyncio
|
||||||
|
async def test_resuming_without_a_stored_identity_fails_instead_of_guessing(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
"""Adopting the message count would relabel a question already in progress.
|
||||||
|
|
||||||
|
Every declaration and epoch comparison in it would then be judged against the
|
||||||
|
wrong question, silently. This state is not one the design produces, so it is
|
||||||
|
reported rather than repaired.
|
||||||
|
"""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
|
||||||
|
async def model(_messages, _info): # pragma: no cover - never reached
|
||||||
|
return ModelResponse(parts=[TextPart("answer")])
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="no stored question identity"):
|
||||||
|
await agent.run(
|
||||||
|
"carry on",
|
||||||
|
deferred_tool_results=DeferredToolResults(
|
||||||
|
calls={"call-2": "external result"}
|
||||||
|
),
|
||||||
|
message_history=[
|
||||||
|
*_in_flight_history(),
|
||||||
|
ModelResponse(parts=[ToolCallPart("external_tool", {}, "call-2")]),
|
||||||
|
],
|
||||||
|
deps=Deps(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_citing_after_searching_grounds_the_question(temp_db_path):
|
||||||
|
"""The whole rule, end to end, with no compactor and no policy capability."""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
calls = iter(
|
||||||
|
[
|
||||||
|
[ToolCallPart("rag_search", {"query": "supervisor"}, "call-1")],
|
||||||
|
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-2")],
|
||||||
|
[TextPart("answer")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
with patch.object(RAGCapability, "_search", _stub_search):
|
||||||
|
await agent.run("what does the supervisor do?", deps=deps)
|
||||||
|
|
||||||
|
record = _record(deps, "rag")
|
||||||
|
question = record.question
|
||||||
|
assert question is not None
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert [ref.chunk_id for ref in record.declaration.refs] == ["chunk-1"]
|
||||||
|
assert record.occurrences["chunk-1"].retrieved_in_questions == [question]
|
||||||
|
assert citation_status([record], question=question) == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_searching_after_citing_leaves_the_question_uncited(temp_db_path):
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
calls = iter(
|
||||||
|
[
|
||||||
|
[ToolCallPart("rag_search", {"query": "supervisor"}, "call-1")],
|
||||||
|
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-2")],
|
||||||
|
[ToolCallPart("rag_search", {"query": "again"}, "call-3")],
|
||||||
|
[TextPart("answer")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
with patch.object(RAGCapability, "_search", _stub_search):
|
||||||
|
await agent.run("what does the supervisor do?", deps=deps)
|
||||||
|
|
||||||
|
record = _record(deps, "rag")
|
||||||
|
question = record.question
|
||||||
|
assert question is not None
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert citation_status([record], question=question) == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_citation_in_the_same_request_as_its_search_is_not_current(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
"""Two calls in one response share an epoch, and citing must follow seeing."""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
calls = iter(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
ToolCallPart("rag_search", {"query": "supervisor"}, "call-1"),
|
||||||
|
ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-2"),
|
||||||
|
],
|
||||||
|
[TextPart("answer")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
with patch.object(RAGCapability, "_search", _stub_search):
|
||||||
|
await agent.run("what does the supervisor do?", deps=deps)
|
||||||
|
|
||||||
|
record = _record(deps, "rag")
|
||||||
|
question = record.question
|
||||||
|
assert question is not None
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert record.declaration.epoch == record.latest_evidence_epoch
|
||||||
|
assert citation_status([record], question=question) == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evidence_cited_in_two_questions_keeps_both_in_the_record(temp_db_path):
|
||||||
|
"""Occurrences outlive the question that wrote them, through the state dict."""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
calls = iter(
|
||||||
|
[
|
||||||
|
[ToolCallPart("rag_search", {"query": "supervisor"}, "call-1")],
|
||||||
|
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-2")],
|
||||||
|
[TextPart("first answer")],
|
||||||
|
[ToolCallPart("rag_search", {"query": "supervisor again"}, "call-3")],
|
||||||
|
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-4")],
|
||||||
|
[TextPart("second answer")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
with patch.object(RAGCapability, "_search", _stub_search):
|
||||||
|
first = await agent.run("who supervises?", deps=deps)
|
||||||
|
first_question = _record(deps, "rag").question
|
||||||
|
await agent.run(
|
||||||
|
"and who supervises them?", deps=deps, message_history=first.all_messages()
|
||||||
|
)
|
||||||
|
|
||||||
|
record = _record(deps, "rag")
|
||||||
|
assert record.occurrences["chunk-1"].cited_in_questions == [
|
||||||
|
first_question,
|
||||||
|
record.question,
|
||||||
|
]
|
||||||
|
assert record.question != first_question
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_run_with_no_prompt_and_no_history_starts_a_question(temp_db_path):
|
||||||
|
"""An instructions-only run is a first question, not a resumption.
|
||||||
|
|
||||||
|
There is no question in progress to keep an identity for, so nothing is
|
||||||
|
missing and the run proceeds with a fresh one.
|
||||||
|
"""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=[TextPart("answer")])
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
await agent.run(deps=deps)
|
||||||
|
|
||||||
|
assert _record(deps, "rag").question == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_citing_without_searching_grounds_the_question(temp_db_path):
|
||||||
|
"""A direct chunk-id citation stands on its own, with no evidence outcome.
|
||||||
|
|
||||||
|
Epochs count messages and so start above zero, which is what lets a
|
||||||
|
declaration made in the first request still beat an empty evidence horizon.
|
||||||
|
"""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
calls = iter(
|
||||||
|
[
|
||||||
|
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-1")],
|
||||||
|
[TextPart("answer")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.get_chunk_by_id.return_value = Chunk(
|
||||||
|
id="chunk-1", document_id="doc-1", content="evidence"
|
||||||
|
)
|
||||||
|
client.get_document_by_id.return_value = None
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
with patch.object(RAGCapability, "_ensure_rag", AsyncMock(return_value=client)):
|
||||||
|
await agent.run("cite chunk-1", deps=deps)
|
||||||
|
|
||||||
|
record = _record(deps, "rag")
|
||||||
|
question = record.question
|
||||||
|
assert question is not None
|
||||||
|
assert record.latest_evidence_epoch == 0
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert record.declaration.epoch > 0
|
||||||
|
assert record.occurrences["chunk-1"].retrieved_in_questions == []
|
||||||
|
assert citation_status([record], question=question) == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_host_seeded_record_does_not_pass_for_a_resumption(temp_db_path):
|
||||||
|
"""A default record is truthy, so its presence cannot stand in for identity.
|
||||||
|
|
||||||
|
Seeding one is what a host does when it has no state to send, and taking it
|
||||||
|
at face value would silently answer as question zero.
|
||||||
|
"""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
|
||||||
|
async def model(_messages, _info): # pragma: no cover - never reached
|
||||||
|
return ModelResponse(parts=[TextPart("answer")])
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="no stored question identity"):
|
||||||
|
await agent.run(
|
||||||
|
"carry on",
|
||||||
|
message_history=_in_flight_history(),
|
||||||
|
deps=Deps(state={"rag": RAGState().model_dump(mode="json")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_resumption_keeps_the_evidence_the_question_already_gathered(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
"""Clearing it would lose the results the model is still answering from.
|
||||||
|
|
||||||
|
A citation after the resumption then records no provenance, and cannot resolve
|
||||||
|
against the expanded search result the model actually saw.
|
||||||
|
"""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
calls = iter(
|
||||||
|
[
|
||||||
|
[ToolCallPart("rag_search", {"query": "supervisor"}, "call-1")],
|
||||||
|
[TextPart("partial answer")],
|
||||||
|
[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-3")],
|
||||||
|
[TextPart("answer")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model(_messages, _info):
|
||||||
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
|
agent = Agent(FunctionModel(model), deps_type=Deps, capabilities=[rag])
|
||||||
|
deps = Deps()
|
||||||
|
|
||||||
|
with patch.object(RAGCapability, "_search", _stub_search):
|
||||||
|
interrupted = await agent.run("what does the supervisor do?", deps=deps)
|
||||||
|
identity = _record(deps, "rag").question
|
||||||
|
assert identity is not None
|
||||||
|
await agent.run(
|
||||||
|
deferred_tool_results=DeferredToolResults(
|
||||||
|
calls={"call-2": "external result"}
|
||||||
|
),
|
||||||
|
message_history=[
|
||||||
|
*interrupted.all_messages(),
|
||||||
|
ModelResponse(parts=[ToolCallPart("external_tool", {}, "call-2")]),
|
||||||
|
],
|
||||||
|
deps=deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
record = _record(deps, "rag")
|
||||||
|
assert record.question == identity
|
||||||
|
assert record.occurrences["chunk-1"].retrieved_in_questions == [identity]
|
||||||
|
assert citation_status([record], question=identity) == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_capability_fetches_its_own_evidences_pictures(temp_db_path):
|
||||||
|
"""Compaction rehydrates through the owner, which already holds the connection."""
|
||||||
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
|
client = AsyncMock()
|
||||||
|
client.document_item_repository.get_picture_bytes.return_value = b"picture-bytes"
|
||||||
|
capability.rag = client
|
||||||
|
|
||||||
|
data = await capability.get_picture_bytes("doc-1", "#/pictures/0")
|
||||||
|
|
||||||
|
assert data == b"picture-bytes"
|
||||||
|
client.document_item_repository.get_picture_bytes.assert_awaited_once_with(
|
||||||
|
"doc-1", "#/pictures/0"
|
||||||
|
)
|
||||||
|
|
|
||||||
386
tests/capabilities/test_evidence_capsule.py
Normal file
386
tests/capabilities/test_evidence_capsule.py
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.exceptions import UserError
|
||||||
|
from pydantic_ai.messages import ModelResponse, TextPart
|
||||||
|
from pydantic_ai.models.function import FunctionModel
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.analysis import create_capability as create_analysis
|
||||||
|
from haiku.rag.capabilities.compaction import (
|
||||||
|
CAPSULE_HEADER,
|
||||||
|
DiscoveredEvidence,
|
||||||
|
EvidenceCompactionCapability,
|
||||||
|
build_capsule,
|
||||||
|
group_label,
|
||||||
|
)
|
||||||
|
from haiku.rag.capabilities.compaction import create_capability as create_compaction
|
||||||
|
from haiku.rag.capabilities.ledger import (
|
||||||
|
CapabilityEvidenceRecord,
|
||||||
|
EvidenceOccurrence,
|
||||||
|
)
|
||||||
|
from haiku.rag.capabilities.rag import create_capability as create_rag
|
||||||
|
from haiku.rag.config.models import AppConfig
|
||||||
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Deps:
|
||||||
|
state: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def citation(chunk_id: str, content: str = "evidence body", pictures=()) -> Citation:
|
||||||
|
return Citation(
|
||||||
|
document_id=f"doc-of-{chunk_id}",
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
document_uri=f"test://{chunk_id}",
|
||||||
|
document_title=f"Title {chunk_id}",
|
||||||
|
content=content,
|
||||||
|
picture_refs=list(pictures),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_citation(cited: Citation, **changes: Any) -> Citation:
|
||||||
|
return cited.model_copy(update=changes)
|
||||||
|
|
||||||
|
|
||||||
|
def discovered(
|
||||||
|
capability: str = "rag",
|
||||||
|
*,
|
||||||
|
cited: dict[str, list[int]] | None = None,
|
||||||
|
contents: dict[str, str] | None = None,
|
||||||
|
pictures: dict[str, list[str]] | None = None,
|
||||||
|
) -> DiscoveredEvidence:
|
||||||
|
"""One capability's records, as the compactor would find them."""
|
||||||
|
cited = cited or {}
|
||||||
|
contents = contents or {}
|
||||||
|
pictures = pictures or {}
|
||||||
|
record = CapabilityEvidenceRecord(question=max(max(cited.values(), default=[0])))
|
||||||
|
for chunk_id, questions in cited.items():
|
||||||
|
record.occurrences[chunk_id] = EvidenceOccurrence(
|
||||||
|
capability=capability,
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
retrieved_in_questions=list(questions),
|
||||||
|
cited_in_questions=list(questions),
|
||||||
|
)
|
||||||
|
return DiscoveredEvidence(
|
||||||
|
capability=capability,
|
||||||
|
record=record,
|
||||||
|
citations={
|
||||||
|
chunk_id: citation(
|
||||||
|
chunk_id,
|
||||||
|
contents.get(chunk_id, "evidence body"),
|
||||||
|
pictures.get(chunk_id, ()),
|
||||||
|
)
|
||||||
|
for chunk_id in cited
|
||||||
|
},
|
||||||
|
tool_names=frozenset({f"{capability}_search"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_cited_produces_no_capsule():
|
||||||
|
capsule = build_capsule([discovered()])
|
||||||
|
|
||||||
|
assert capsule.text == ""
|
||||||
|
assert capsule.pictures == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cited_evidence_is_grouped_newest_question_first():
|
||||||
|
capsule = build_capsule([discovered(cited={"old": [2], "new": [8]})])
|
||||||
|
|
||||||
|
assert capsule.text.index(group_label(1)) < capsule.text.index(group_label(2))
|
||||||
|
assert capsule.text.index("[new]") < capsule.text.index("[old]")
|
||||||
|
assert CAPSULE_HEADER in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_entry_is_rendered_once_in_its_most_recent_citing_group():
|
||||||
|
capsule = build_capsule([discovered(cited={"reused": [2, 8], "only-old": [2]})])
|
||||||
|
|
||||||
|
assert capsule.text.count("[reused]") == 1
|
||||||
|
assert capsule.text.index("[reused]") < capsule.text.index("[only-old]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_cited_in_one_question_forms_one_group():
|
||||||
|
capsule = build_capsule([discovered(cited={"a": [4], "b": [4]})])
|
||||||
|
|
||||||
|
assert group_label(1) in capsule.text
|
||||||
|
assert group_label(2) not in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_cited_entry_is_kept_whole():
|
||||||
|
"""No budget: a long citation is retained in full rather than truncated."""
|
||||||
|
body = "L" * 20_000
|
||||||
|
capsule = build_capsule([discovered(cited={"long": [4]}, contents={"long": body})])
|
||||||
|
|
||||||
|
assert body in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_capabilities_share_one_capsule():
|
||||||
|
capsule = build_capsule(
|
||||||
|
[
|
||||||
|
discovered("rag", cited={"from-rag": [4]}),
|
||||||
|
discovered("analysis", cited={"from-analysis": [6]}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert capsule.text.count(CAPSULE_HEADER) == 1
|
||||||
|
assert "[from-rag]" in capsule.text
|
||||||
|
assert "[from-analysis]" in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_chunk_id_under_two_capabilities_is_kept_apart():
|
||||||
|
capsule = build_capsule(
|
||||||
|
[
|
||||||
|
discovered("rag", cited={"shared": [4]}, contents={"shared": "rag body"}),
|
||||||
|
discovered(
|
||||||
|
"analysis", cited={"shared": [4]}, contents={"shared": "analysis body"}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "rag body" in capsule.text
|
||||||
|
assert "analysis body" in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_cited_evidence_with_no_canonical_citation_is_an_error():
|
||||||
|
"""Both are written by the same call, so divergence is not a valid state.
|
||||||
|
|
||||||
|
Rendering the rest would quietly drop evidence an answer rested on, against
|
||||||
|
the one guarantee this capsule makes.
|
||||||
|
"""
|
||||||
|
evidence = discovered(cited={"present": [4]})
|
||||||
|
evidence.record.occurrences["absent"] = EvidenceOccurrence(
|
||||||
|
capability="rag", chunk_id="absent", cited_in_questions=[4]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="absent"):
|
||||||
|
build_capsule([evidence])
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieved_but_uncited_evidence_is_not_kept():
|
||||||
|
evidence = discovered(cited={"cited": [4]})
|
||||||
|
evidence.record.occurrences["seen-only"] = EvidenceOccurrence(
|
||||||
|
capability="rag", chunk_id="seen-only", retrieved_in_questions=[4]
|
||||||
|
)
|
||||||
|
|
||||||
|
capsule = build_capsule([evidence])
|
||||||
|
|
||||||
|
assert "[cited]" in capsule.text
|
||||||
|
assert "seen-only" not in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_source_is_named_once_when_the_title_is_the_uri():
|
||||||
|
"""Real corpora set both to the document id, which reads as a stutter."""
|
||||||
|
evidence = discovered(cited={"a": [4]})
|
||||||
|
evidence.citations["a"].document_title = "2410.11843v5"
|
||||||
|
evidence.citations["a"].document_uri = "2410.11843v5"
|
||||||
|
|
||||||
|
capsule = build_capsule([evidence])
|
||||||
|
|
||||||
|
assert 'Source: "2410.11843v5"' in capsule.text
|
||||||
|
assert "2410.11843v5)" not in capsule.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_pictures_of_cited_evidence_are_all_retained_newest_first():
|
||||||
|
capsule = build_capsule(
|
||||||
|
[
|
||||||
|
discovered(
|
||||||
|
cited={"a": [2], "c": [6]},
|
||||||
|
pictures={"a": ["#/pictures/0"], "c": ["#/pictures/1", "#/pictures/2"]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [picture.self_ref for picture in capsule.pictures] == [
|
||||||
|
"#/pictures/1",
|
||||||
|
"#/pictures/2",
|
||||||
|
"#/pictures/0",
|
||||||
|
]
|
||||||
|
assert capsule.pictures[0].document_id == "doc-of-c"
|
||||||
|
assert capsule.pictures[0].capability == "rag"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_picture_of_uncited_evidence_is_not_retained():
|
||||||
|
found = discovered(cited={"cited": [4]}, pictures={"cited": ["#/pictures/0"]})
|
||||||
|
found.record.occurrences["seen-only"] = EvidenceOccurrence(
|
||||||
|
capability="rag", chunk_id="seen-only", retrieved_in_questions=[4]
|
||||||
|
)
|
||||||
|
evidence = replace(
|
||||||
|
found,
|
||||||
|
citations={
|
||||||
|
**found.citations,
|
||||||
|
"seen-only": citation("seen-only", pictures=["#/pictures/9"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
capsule = build_capsule([evidence])
|
||||||
|
|
||||||
|
assert [picture.self_ref for picture in capsule.pictures] == ["#/pictures/0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_picture_cited_through_two_chunks_is_attached_once():
|
||||||
|
"""Overlapping chunks of one document share a figure, counted twice by a provider."""
|
||||||
|
found = discovered(
|
||||||
|
cited={"first": [4], "second": [4]},
|
||||||
|
pictures={"first": ["#/pictures/1"], "second": ["#/pictures/1"]},
|
||||||
|
)
|
||||||
|
shared = {
|
||||||
|
chunk_id: replace_citation(cited, document_id="doc-shared")
|
||||||
|
for chunk_id, cited in found.citations.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
capsule = build_capsule([replace(found, citations=shared)])
|
||||||
|
|
||||||
|
assert len(capsule.pictures) == 1
|
||||||
|
assert capsule.pictures[0].chunk_id == "first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_reference_in_two_documents_is_kept_twice():
|
||||||
|
"""``#/pictures/1`` means a different figure in a different document.
|
||||||
|
|
||||||
|
One capability throughout, so only the document differs: dropping the document
|
||||||
|
from the identity would have to fail this.
|
||||||
|
"""
|
||||||
|
capsule = build_capsule(
|
||||||
|
[
|
||||||
|
discovered(
|
||||||
|
"rag",
|
||||||
|
cited={"a": [4], "b": [4]},
|
||||||
|
pictures={"a": ["#/pictures/1"], "b": ["#/pictures/1"]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(capsule.pictures) == 2
|
||||||
|
assert {picture.capability for picture in capsule.pictures} == {"rag"}
|
||||||
|
assert {picture.document_id for picture in capsule.pictures} == {
|
||||||
|
"doc-of-a",
|
||||||
|
"doc-of-b",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_picture_label_names_the_chunk_it_belongs_to():
|
||||||
|
capsule = build_capsule(
|
||||||
|
[discovered(cited={"a": [4]}, pictures={"a": ["#/pictures/0"]})]
|
||||||
|
)
|
||||||
|
|
||||||
|
label = capsule.pictures[0].label
|
||||||
|
assert "[a]" in label
|
||||||
|
assert "#/pictures/0" in label
|
||||||
|
assert "knowledge base" in label
|
||||||
|
assert "Not provided by the user" in label
|
||||||
|
|
||||||
|
|
||||||
|
def _spy_discovery(found: list[list[DiscoveredEvidence]]):
|
||||||
|
"""Discover from ``before_run``, the earliest point the registry is reliable."""
|
||||||
|
original = EvidenceCompactionCapability.before_run
|
||||||
|
|
||||||
|
async def spy(self, ctx):
|
||||||
|
await original(self, ctx)
|
||||||
|
found.append(self.discover(ctx))
|
||||||
|
|
||||||
|
return patch.object(EvidenceCompactionCapability, "before_run", spy)
|
||||||
|
|
||||||
|
|
||||||
|
async def _answer(_messages, _info):
|
||||||
|
return ModelResponse(parts=[TextPart("answer")])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_compactor_discovers_both_evidence_capabilities(temp_db_path):
|
||||||
|
"""Discovery runs one way through the registry, so nothing needs wiring."""
|
||||||
|
compactor = create_compaction()
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
analysis = create_analysis(
|
||||||
|
db_path=temp_db_path, config=AppConfig(), defer_loading=False
|
||||||
|
)
|
||||||
|
found: list[list[DiscoveredEvidence]] = []
|
||||||
|
|
||||||
|
with _spy_discovery(found):
|
||||||
|
agent = Agent(
|
||||||
|
FunctionModel(_answer),
|
||||||
|
deps_type=Deps,
|
||||||
|
capabilities=[rag, analysis, compactor],
|
||||||
|
)
|
||||||
|
await agent.run("a question", deps=Deps())
|
||||||
|
|
||||||
|
assert {evidence.capability: set(evidence.tool_names) for evidence in found[0]} == {
|
||||||
|
"rag": {"rag_search"},
|
||||||
|
"analysis": {"analysis_search", "analysis_execute_code"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discovery_sees_the_run_instances_not_the_registered_ones(temp_db_path):
|
||||||
|
"""A registered capability holds no state; only its per-run copy does."""
|
||||||
|
compactor = create_compaction()
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
found: list[list[DiscoveredEvidence]] = []
|
||||||
|
|
||||||
|
with _spy_discovery(found):
|
||||||
|
agent = Agent(
|
||||||
|
FunctionModel(_answer), deps_type=Deps, capabilities=[rag, compactor]
|
||||||
|
)
|
||||||
|
await agent.run("a question", deps=Deps())
|
||||||
|
|
||||||
|
assert rag.state is None
|
||||||
|
assert found[0][0].record.question == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_compactors_fail_fast(temp_db_path):
|
||||||
|
"""Each would rewrite the same history and each would build its own capsule.
|
||||||
|
|
||||||
|
They share this capability's id, so pydantic-ai refuses at construction and
|
||||||
|
nothing here has to police it.
|
||||||
|
"""
|
||||||
|
rag = create_rag(db_path=temp_db_path, config=AppConfig(), defer_loading=False)
|
||||||
|
|
||||||
|
with pytest.raises(UserError, match="unique within a run"):
|
||||||
|
Agent(
|
||||||
|
FunctionModel(_answer),
|
||||||
|
deps_type=Deps,
|
||||||
|
capabilities=[rag, create_compaction(), create_compaction()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_compactor_alone_discovers_nothing_and_still_runs():
|
||||||
|
found: list[list[DiscoveredEvidence]] = []
|
||||||
|
|
||||||
|
with _spy_discovery(found):
|
||||||
|
agent = Agent(
|
||||||
|
FunctionModel(_answer), deps_type=Deps, capabilities=[create_compaction()]
|
||||||
|
)
|
||||||
|
result = await agent.run("a question", deps=Deps())
|
||||||
|
|
||||||
|
assert found == [[]]
|
||||||
|
assert result.output == "answer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_deferred_capability_the_model_never_loaded_has_an_empty_record(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
"""It is still discovered, because every registered capability gets a run copy.
|
||||||
|
|
||||||
|
Nothing was retrieved under it, so its record contributes no entries and the
|
||||||
|
compactor needs no special case for it.
|
||||||
|
"""
|
||||||
|
deferred = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
|
found: list[list[DiscoveredEvidence]] = []
|
||||||
|
|
||||||
|
with _spy_discovery(found):
|
||||||
|
agent = Agent(
|
||||||
|
FunctionModel(_answer),
|
||||||
|
deps_type=Deps,
|
||||||
|
capabilities=[deferred, create_compaction()],
|
||||||
|
)
|
||||||
|
await agent.run("a question", deps=Deps())
|
||||||
|
|
||||||
|
assert deferred.defer_loading is True
|
||||||
|
assert deferred.state is None
|
||||||
|
assert [evidence.capability for evidence in found[0]] == ["rag"]
|
||||||
|
assert found[0][0].record.occurrences == {}
|
||||||
|
assert build_capsule(found[0]).text == ""
|
||||||
242
tests/capabilities/test_evidence_ledger.py
Normal file
242
tests/capabilities/test_evidence_ledger.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.ledger import (
|
||||||
|
CapabilityEvidenceRecord,
|
||||||
|
CitationDeclaration,
|
||||||
|
EvidenceRef,
|
||||||
|
citation_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rag_ref(chunk_id: str = "c1") -> EvidenceRef:
|
||||||
|
return EvidenceRef(capability="rag", chunk_id=chunk_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_record_survives_the_state_round_trip():
|
||||||
|
"""Capability state is persisted as JSON, so the schema must survive it.
|
||||||
|
|
||||||
|
A dict keyed by ``(capability, chunk_id)`` does not: the key serialises to
|
||||||
|
``"rag,c1"`` and fails revalidation as a tuple.
|
||||||
|
"""
|
||||||
|
record = CapabilityEvidenceRecord(question=4)
|
||||||
|
record.note_evidence(5)
|
||||||
|
record.declare([rag_ref()], epoch=7, retrieved_now={"c1"})
|
||||||
|
|
||||||
|
restored = CapabilityEvidenceRecord.model_validate(record.model_dump(mode="json"))
|
||||||
|
|
||||||
|
assert restored == record
|
||||||
|
assert citation_status([restored], question=4) == "grounded"
|
||||||
|
assert restored.occurrences["c1"].cited_in_questions == [4]
|
||||||
|
assert restored.occurrences["c1"].retrieved_in_questions == [4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_declaration_reads_as_missing():
|
||||||
|
assert citation_status([CapabilityEvidenceRecord()], question=0) == "missing"
|
||||||
|
assert citation_status([], question=0) == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refs_make_it_grounded_and_no_refs_make_it_ungrounded():
|
||||||
|
grounded = CapabilityEvidenceRecord(question=0)
|
||||||
|
grounded.declare([rag_ref()], epoch=1)
|
||||||
|
|
||||||
|
ungrounded = CapabilityEvidenceRecord(question=0)
|
||||||
|
ungrounded.declare([], epoch=1)
|
||||||
|
|
||||||
|
assert citation_status([grounded], question=0) == "grounded"
|
||||||
|
assert citation_status([ungrounded], question=0) == "ungrounded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_earlier_questions_declaration_is_never_current():
|
||||||
|
"""A later question inherits nothing: it has declared nothing yet."""
|
||||||
|
record = CapabilityEvidenceRecord(question=2)
|
||||||
|
record.declare([rag_ref()], epoch=3)
|
||||||
|
assert citation_status([record], question=2) == "grounded"
|
||||||
|
|
||||||
|
record.begin_question(8)
|
||||||
|
|
||||||
|
assert citation_status([record], question=8) == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_citation_in_the_same_request_as_the_evidence_is_not_current():
|
||||||
|
"""Citing must follow seeing: equal epochs mean one request."""
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.note_evidence(5)
|
||||||
|
record.declare([rag_ref()], epoch=5)
|
||||||
|
|
||||||
|
assert citation_status([record], question=0) == "missing"
|
||||||
|
|
||||||
|
record.declare([rag_ref()], epoch=7)
|
||||||
|
|
||||||
|
assert citation_status([record], question=0) == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_from_another_capability_after_citing_makes_it_uncited():
|
||||||
|
"""Currency spans capabilities, which only works because epochs are global."""
|
||||||
|
cited = CapabilityEvidenceRecord(question=0)
|
||||||
|
cited.note_evidence(3)
|
||||||
|
cited.declare([rag_ref()], epoch=5)
|
||||||
|
searched_after = CapabilityEvidenceRecord(question=0)
|
||||||
|
searched_after.note_evidence(7)
|
||||||
|
|
||||||
|
assert citation_status([cited], question=0) == "grounded"
|
||||||
|
assert citation_status([cited, searched_after], question=0) == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declarations_at_the_same_epoch_merge():
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.declare([rag_ref("c1")], epoch=3)
|
||||||
|
record.declare([rag_ref("c2")], epoch=3)
|
||||||
|
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert [ref.chunk_id for ref in record.declaration.refs] == ["c1", "c2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeating_a_ref_at_the_same_epoch_does_not_duplicate_it():
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.declare([rag_ref()], epoch=3)
|
||||||
|
record.declare([rag_ref()], epoch=3)
|
||||||
|
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert len(record.declaration.refs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_neither_cite_order_downgrades_a_grounded_declaration():
|
||||||
|
grounded_then_empty = CapabilityEvidenceRecord(question=0)
|
||||||
|
grounded_then_empty.declare([rag_ref()], epoch=3)
|
||||||
|
grounded_then_empty.declare([], epoch=3)
|
||||||
|
|
||||||
|
empty_then_grounded = CapabilityEvidenceRecord(question=0)
|
||||||
|
empty_then_grounded.declare([], epoch=3)
|
||||||
|
empty_then_grounded.declare([rag_ref()], epoch=3)
|
||||||
|
|
||||||
|
assert citation_status([grounded_then_empty], question=0) == "grounded"
|
||||||
|
assert citation_status([empty_then_grounded], question=0) == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_chunk_id_under_two_capabilities_stays_separate():
|
||||||
|
rag = CapabilityEvidenceRecord(question=0)
|
||||||
|
rag.declare([EvidenceRef(capability="rag", chunk_id="shared")], epoch=3)
|
||||||
|
analysis = CapabilityEvidenceRecord(question=0)
|
||||||
|
analysis.declare([EvidenceRef(capability="analysis", chunk_id="shared")], epoch=3)
|
||||||
|
|
||||||
|
assert rag.occurrences["shared"].capability == "rag"
|
||||||
|
assert analysis.occurrences["shared"].capability == "analysis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_citing_the_same_chunk_in_two_questions_records_both():
|
||||||
|
record = CapabilityEvidenceRecord(question=2)
|
||||||
|
record.declare([rag_ref()], epoch=3, retrieved_now={"c1"})
|
||||||
|
record.begin_question(8)
|
||||||
|
record.declare([rag_ref()], epoch=9)
|
||||||
|
|
||||||
|
occurrence = record.occurrences["c1"]
|
||||||
|
assert occurrence.cited_in_questions == [2, 8]
|
||||||
|
assert occurrence.retrieved_in_questions == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declaration_records_the_question_and_epoch_it_was_made_at():
|
||||||
|
record = CapabilityEvidenceRecord(question=6)
|
||||||
|
record.declare([rag_ref()], epoch=11)
|
||||||
|
|
||||||
|
assert record.declaration == CitationDeclaration(
|
||||||
|
question=6, epoch=11, refs=[rag_ref()]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_record_has_no_question_identity():
|
||||||
|
"""The identity is established by the run, and its absence must be detectable.
|
||||||
|
|
||||||
|
A default record is truthy, so its mere presence cannot stand in for having
|
||||||
|
been through ``for_run``: a host that seeds one would otherwise pass the
|
||||||
|
resumption check with a fabricated identity of zero.
|
||||||
|
"""
|
||||||
|
assert CapabilityEvidenceRecord().question is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_cannot_move_backwards_in_the_conversation():
|
||||||
|
"""Epochs are message counts, and currency depends on them only growing.
|
||||||
|
|
||||||
|
Silently keeping the newer value would leave every later declaration stale
|
||||||
|
for the rest of the conversation, permanently and invisibly.
|
||||||
|
"""
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.note_evidence(9)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="append-only"):
|
||||||
|
record.note_evidence(4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_citing_before_a_run_establishes_the_question_is_refused():
|
||||||
|
with pytest.raises(ValueError, match="question identity"):
|
||||||
|
CapabilityEvidenceRecord().declare([rag_ref()], epoch=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declaration_cannot_move_backwards():
|
||||||
|
"""Otherwise a stale citation replaces a newer one and revives the answer."""
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.declare([rag_ref("newer")], epoch=5)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="append-only"):
|
||||||
|
record.declare([rag_ref("older")], epoch=3)
|
||||||
|
|
||||||
|
assert record.declaration is not None
|
||||||
|
assert [ref.chunk_id for ref in record.declaration.refs] == ["newer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_cannot_predate_a_recorded_declaration():
|
||||||
|
"""The declaration's epoch is a recorded message count like any other."""
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.declare([rag_ref()], epoch=5)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="append-only"):
|
||||||
|
record.note_evidence(3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_question_starts_behind_the_epochs_of_the_one_before_it():
|
||||||
|
"""A question's identity is the history it arrives on, not a continuation.
|
||||||
|
|
||||||
|
Epochs are compared only within the question that recorded them, so a host
|
||||||
|
whose stored history shifted between two questions is answered rather than
|
||||||
|
refused.
|
||||||
|
"""
|
||||||
|
record = CapabilityEvidenceRecord(question=0)
|
||||||
|
record.note_evidence(9)
|
||||||
|
|
||||||
|
record.begin_question(4)
|
||||||
|
|
||||||
|
assert record.question == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_question_cannot_reuse_the_identity_of_the_one_before_it():
|
||||||
|
"""Occurrences outlive their question and are ordered by identity.
|
||||||
|
|
||||||
|
Two questions sharing one identity merge into a single capsule group, and a
|
||||||
|
lower one is rendered as though its evidence were cited earlier.
|
||||||
|
"""
|
||||||
|
record = CapabilityEvidenceRecord(question=4)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already answered"):
|
||||||
|
record.begin_question(4)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already answered"):
|
||||||
|
record.begin_question(3)
|
||||||
|
|
||||||
|
assert record.question == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_question_starts_clear_of_the_one_before_it():
|
||||||
|
"""Evidence and declarations describe a single question and end with it.
|
||||||
|
|
||||||
|
Carrying either into the next question makes it answerable by the last
|
||||||
|
question's citations, and freezes its own declarations behind an epoch no
|
||||||
|
message in it can reach.
|
||||||
|
"""
|
||||||
|
record = CapabilityEvidenceRecord(question=4)
|
||||||
|
record.note_evidence(6)
|
||||||
|
record.declare([rag_ref()], epoch=7)
|
||||||
|
|
||||||
|
record.begin_question(9)
|
||||||
|
|
||||||
|
assert record.latest_evidence_epoch == 0
|
||||||
|
assert record.declaration is None
|
||||||
|
assert citation_status([record], question=9) == "missing"
|
||||||
729
tests/capabilities/test_evidence_wire.py
Normal file
729
tests/capabilities/test_evidence_wire.py
Normal file
|
|
@ -0,0 +1,729 @@
|
||||||
|
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 already identified."""
|
||||||
|
return Deps(
|
||||||
|
state={
|
||||||
|
"rag": RAGState(
|
||||||
|
evidence=CapabilityEvidenceRecord(question=question)
|
||||||
|
).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"]
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
@ -412,3 +413,55 @@ async def test_chat_app_open_failure_surfaces_real_error(tmp_path: Path):
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
async with app.run_test():
|
async with app.run_test():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_cancelled_run_does_not_advance_persisted_state(temp_db_path: Path):
|
||||||
|
"""State and message history have to move together, or the thread bricks.
|
||||||
|
|
||||||
|
A cancelled run keeps whatever the tools wrote but discards the run's messages.
|
||||||
|
If the state advanced, the next question derives its identity from the shorter
|
||||||
|
history, lands behind the recorded evidence epoch, and is refused as
|
||||||
|
non-append-only — leaving the conversation unusable until cleared.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
app, mock_client = _make_app(temp_db_path)
|
||||||
|
|
||||||
|
class CancellingRun:
|
||||||
|
"""A run that writes evidence through the tools, then is cancelled."""
|
||||||
|
|
||||||
|
def __init__(self, deps):
|
||||||
|
self._deps = deps
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
self._deps.state["rag"] = {
|
||||||
|
"evidence": {"question": 0, "latest_evidence_epoch": 7}
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
with patch("haiku.rag.chat.app.HaikuRAG", return_value=mock_client):
|
||||||
|
async with app.run_test():
|
||||||
|
app._state = {
|
||||||
|
"rag": {"evidence": {"question": 0, "latest_evidence_epoch": 0}}
|
||||||
|
}
|
||||||
|
before = deepcopy(app._state)
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def run_stream_events(self, *_, deps, **__):
|
||||||
|
return CancellingRun(deps)
|
||||||
|
|
||||||
|
app._agent = Agent() # type: ignore[assignment]
|
||||||
|
await app._run_agent("a question")
|
||||||
|
|
||||||
|
assert app._state == before
|
||||||
|
assert app._messages == []
|
||||||
|
|
|
||||||
|
|
@ -402,8 +402,9 @@ async def test_search_tool_returns_multimodal_when_picture_present():
|
||||||
assert isinstance(result.return_value, str)
|
assert isinstance(result.return_value, str)
|
||||||
assert "Type: picture" in result.return_value or "rank 1" in result.return_value
|
assert "Type: picture" in result.return_value or "rank 1" in result.return_value
|
||||||
assert result.content is not None
|
assert result.content is not None
|
||||||
assert len(result.content) == 1
|
images = [c for c in result.content if isinstance(c, BinaryContent)]
|
||||||
part = result.content[0]
|
assert len(images) == 1
|
||||||
|
part = images[0]
|
||||||
assert isinstance(part, BinaryContent)
|
assert isinstance(part, BinaryContent)
|
||||||
assert part.media_type == "image/png"
|
assert part.media_type == "image/png"
|
||||||
assert part.identifier == "#/pictures/0"
|
assert part.identifier == "#/pictures/0"
|
||||||
|
|
@ -457,11 +458,12 @@ async def test_search_tool_attaches_same_self_ref_from_different_documents():
|
||||||
|
|
||||||
assert isinstance(result, ToolReturn)
|
assert isinstance(result, ToolReturn)
|
||||||
assert result.content is not None
|
assert result.content is not None
|
||||||
assert len(result.content) == 2, (
|
images = [c for c in result.content if isinstance(c, BinaryContent)]
|
||||||
|
assert len(images) == 2, (
|
||||||
"Both documents' figures must reach the model — dedup keyed on "
|
"Both documents' figures must reach the model — dedup keyed on "
|
||||||
"self_ref alone would drop doc-B's bytes."
|
"self_ref alone would drop doc-B's bytes."
|
||||||
)
|
)
|
||||||
payloads = {part.data for part in result.content} # type: ignore[attr-defined]
|
payloads = {part.data for part in images}
|
||||||
assert PICTURE_BYTES in payloads
|
assert PICTURE_BYTES in payloads
|
||||||
assert other_bytes in payloads
|
assert other_bytes in payloads
|
||||||
|
|
||||||
|
|
@ -905,7 +907,7 @@ async def test_search_tool_drops_invalid_image_bytes():
|
||||||
|
|
||||||
assert isinstance(result, ToolReturn)
|
assert isinstance(result, ToolReturn)
|
||||||
assert result.content is not None
|
assert result.content is not None
|
||||||
identifiers = {p.identifier for p in result.content} # type: ignore[attr-defined]
|
identifiers = {p.identifier for p in result.content if isinstance(p, BinaryContent)}
|
||||||
assert identifiers == {"#/pictures/0"}, (
|
assert identifiers == {"#/pictures/0"}, (
|
||||||
"Only the decodable PNG should reach the model — the corrupt "
|
"Only the decodable PNG should reach the model — the corrupt "
|
||||||
"ref must be dropped so we don't emit a placeholder for an "
|
"ref must be dropped so we don't emit a placeholder for an "
|
||||||
|
|
|
||||||
|
|
@ -210,30 +210,35 @@ def search_config():
|
||||||
return Config
|
return Config
|
||||||
|
|
||||||
|
|
||||||
class TestBuildBinaryPartsFromResults:
|
def _png_b64():
|
||||||
"""Picture bytes are attached once per (document, self_ref) pair."""
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
PILImage.new("RGB", (4, 4), "red").save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildImageContentFromResults:
|
||||||
|
"""Picture bytes are attached once per (document, self_ref) pair, and labelled."""
|
||||||
|
|
||||||
def test_results_without_image_data_contribute_nothing(self):
|
def test_results_without_image_data_contribute_nothing(self):
|
||||||
from haiku.rag.tools.search import build_binary_parts_from_results
|
from haiku.rag.tools.search import build_image_content_from_results
|
||||||
|
|
||||||
results = [
|
results = [
|
||||||
SearchResult(content="text only", score=0.5, chunk_id="c1", image_data=None)
|
SearchResult(content="text only", score=0.5, chunk_id="c1", image_data=None)
|
||||||
]
|
]
|
||||||
|
|
||||||
assert build_binary_parts_from_results(results) == []
|
assert build_image_content_from_results(results) == []
|
||||||
|
|
||||||
def test_duplicate_document_and_ref_is_attached_once(self):
|
def test_duplicate_document_and_ref_is_attached_once(self):
|
||||||
import base64
|
from pydantic_ai.messages import BinaryContent
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from PIL import Image as PILImage
|
from haiku.rag.tools.search import build_image_content_from_results
|
||||||
|
|
||||||
from haiku.rag.tools.search import build_binary_parts_from_results
|
shared = {"#/pictures/0": _png_b64()}
|
||||||
|
|
||||||
buf = BytesIO()
|
|
||||||
PILImage.new("RGB", (4, 4), "red").save(buf, format="PNG")
|
|
||||||
png = base64.b64encode(buf.getvalue()).decode()
|
|
||||||
shared = {"#/pictures/0": png}
|
|
||||||
results = [
|
results = [
|
||||||
SearchResult(
|
SearchResult(
|
||||||
content="a",
|
content="a",
|
||||||
|
|
@ -251,6 +256,53 @@ class TestBuildBinaryPartsFromResults:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
parts = build_binary_parts_from_results(results)
|
content = build_image_content_from_results(results)
|
||||||
|
|
||||||
assert len(parts) == 1
|
images = [item for item in content if isinstance(item, BinaryContent)]
|
||||||
|
assert len(images) == 1
|
||||||
|
|
||||||
|
def test_each_image_is_labelled_with_the_result_it_belongs_to(self):
|
||||||
|
"""Label every picture, not just the batch.
|
||||||
|
|
||||||
|
``ToolReturn.content`` reaches the model as a user-role message, and one
|
||||||
|
leading note does not override that: with a single note on the wire,
|
||||||
|
gemma4-26b still reasoned "the user also provided images in the prompt".
|
||||||
|
A label adjacent to each picture also names the chunk to cite for it,
|
||||||
|
which ``BinaryContent.identifier`` cannot do — it does not survive
|
||||||
|
serialization to the vision API.
|
||||||
|
"""
|
||||||
|
from pydantic_ai.messages import BinaryContent
|
||||||
|
|
||||||
|
from haiku.rag.tools.search import build_image_content_from_results
|
||||||
|
|
||||||
|
results = [
|
||||||
|
SearchResult(
|
||||||
|
content="a",
|
||||||
|
score=0.9,
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="doc-1",
|
||||||
|
image_data={"#/pictures/0": _png_b64()},
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
content="b",
|
||||||
|
score=0.8,
|
||||||
|
chunk_id="c2",
|
||||||
|
document_id="doc-2",
|
||||||
|
image_data={"#/pictures/3": _png_b64()},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
content = build_image_content_from_results(results)
|
||||||
|
|
||||||
|
# label, image, label, image — each picture preceded by its own line.
|
||||||
|
assert [type(item) is str for item in content] == [True, False, True, False]
|
||||||
|
assert isinstance(content[1], BinaryContent)
|
||||||
|
assert isinstance(content[3], BinaryContent)
|
||||||
|
|
||||||
|
first, second = content[0], content[2]
|
||||||
|
assert isinstance(first, str) and isinstance(second, str)
|
||||||
|
assert "c1" in first and "#/pictures/0" in first
|
||||||
|
assert "c2" in second and "#/pictures/3" in second
|
||||||
|
assert "1 of 2" in first and "2 of 2" in second
|
||||||
|
for label in (first, second):
|
||||||
|
assert "not provided by the user" in label.lower()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue