RAGState and AnalysisState each declared the same five fields, so the generic base could not name them: StateT was bound to BaseModel, and every access went through cast(Any, state), a getattr by string, or a loop clearing fields by name so it could skip the one only AnalysisState has. EvidenceState declares them once. RAGState adds nothing, AnalysisState adds executions and overrides begin_invocation to clear them. StateT binds to EvidenceState, which removes all ten casts and both state-shape getattrs; the three getattr(ctx.deps, "state") probes stay, since those check a host-supplied object rather than our own state. discover_evidence reached into capability.state for two fields. It now asks through evidence_record() and citation_index(), alongside the evidence_tool_names() and cite_available accessors it already used. The eval runner's _RagLikeState protocol and the chat app's getattr reads described this shape from outside and are gone. Compatibility is semantic JSON-object equivalence, not bytes: field names and nesting are unchanged, so a dict stored by 0.75.0 loads and re-dumps equal, but deriving from a shared base reorders AnalysisState's keys. Nothing serializes, hashes or string-compares this state — every carry point re-validates by key.
67 lines
2.4 KiB
Python
67 lines
2.4 KiB
Python
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from pydantic_ai import RunContext
|
|
|
|
from haiku.rag.capabilities._base import RAGCapabilityBase
|
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
|
|
from haiku.rag.store.models.citation import Citation
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DiscoveredEvidence:
|
|
"""One evidence capability's records, as another capability found them.
|
|
|
|
Read-only and rebuilt per request: whoever discovers these merges them into a
|
|
view and persists nothing about evidence itself.
|
|
"""
|
|
|
|
capability: str
|
|
record: CapabilityEvidenceRecord
|
|
citations: Mapping[str, Citation]
|
|
tool_names: frozenset[str]
|
|
cite_available: bool
|
|
state_carried: bool
|
|
|
|
|
|
def discover_evidence(ctx: RunContext[Any]) -> list[DiscoveredEvidence]:
|
|
"""Read what each evidence capability recorded, without writing anything.
|
|
|
|
Discovery runs one way through the run's capability registry, so no capability
|
|
holds a reference to another, and a host running one, both, or neither needs no
|
|
wiring change. 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
|
|
discovered.append(
|
|
DiscoveredEvidence(
|
|
capability=capability.state_namespace,
|
|
record=capability.evidence_record(),
|
|
citations=capability.citation_index(),
|
|
tool_names=frozenset(capability.evidence_tool_names()),
|
|
cite_available=capability.cite_available,
|
|
state_carried=capability.state_carried,
|
|
)
|
|
)
|
|
return sorted(discovered, key=lambda evidence: evidence.capability)
|
|
|
|
|
|
def question_in_progress(evidence: list[DiscoveredEvidence]) -> int:
|
|
"""The identity every evidence capability agrees this question has.
|
|
|
|
They all derive it from the same history, so they agree; taking the maximum
|
|
rather than a first entry keeps the result independent of ordering.
|
|
"""
|
|
return max((found.record.question or 0 for found in evidence), default=0)
|
|
|
|
|
|
__all__ = [
|
|
"DiscoveredEvidence",
|
|
"discover_evidence",
|
|
"question_in_progress",
|
|
]
|