HAIKU_RAG_DB and DB_PATH are gone: a capability covers what the
configuration places or the db_path it is given, and the app backend and
the AG-UI example load their configuration as the CLI does. The compose
files point HAIKU_RAG_CONFIG_PATH at the mounted haiku.rag.yaml, which
places the database at /data where DB_VOLUME is mounted; the backend
refuses a configured set since it serves one database. The chat scopes a
selection by source only over a set and names databases on filter rows
only across several. Docstrings, docs and test fixtures stop describing an
unnamed database; every database a search, listing or citation reports
carries a name.
Sibling searches emitted in one response overlap heavily (40.6% of
returned chunk slots on Glimmer ORB fan-out cases). A result whose
rendered evidence a sibling already showed keeps its rank slot but
collapses to a reference line, and a picture attaches once per response
keyed on (source, document_id, self_ref). Equivalence is the
format_for_agent rendering at neutral rank/total plus picture keys,
bucketed under the qualified chunk id, so another database's copy or a
different expansion of the same anchor formats in full.
Search state now commits only after formatting and image construction
succeed: a raising image build no longer leaves results citable that
the model never saw, notes evidence for them, or suppresses a later
sibling.
Searches a model emits in one response share a budget unit, up to
FREE_SIBLINGS_PER_ROUND (3) per unit; sequential searches pay one unit
each, as before. Grouping keys on RunContext.run_step, which pydantic-ai
increments once per model request. A budget-rejected round fails all its
remaining siblings, and tracking resets per run.
Glimmer opens most questions with a burst of ~3 rephrasings in a single
response (95.8% of its three-search ORB cases are one-response bursts),
spending 3 of 5 searches before reading anything. Pass rate at 3 calls
equals 1 call, so a burst is priced as one probe.
`_rich_print_document` escapes uri, title and metadata, the sibling of
the escaped search-result renderer. The remaining comments and
docstrings that narrated rejected alternatives, consequences or history
now state the current invariant. The Sandbox class docstring names the
held connection close() releases, and wrapped docs paragraphs join to
one line.
Comments and docstrings across the branch narrated rejected
alternatives, consequences and history; each now states the current
contract. Renames test_a_legacy_uri_client_keeps_its_error to
test_an_unnamed_database_keeps_its_error. Documents the Sandbox
connection paths, the citation header's database segment, both
AmbiguousDatabaseError conditions on create_app, and run_inspector's
scope parameter. Doc paragraphs added by the branch in python.md,
storage.md and cli.md are one physical line each.
Comments that narrated a failure mode across three or four lines say the
invariant they protect instead: the repository's late embedding, one database
keeping its hybrid scores, one over-fetch decision for a selection, and a cite
fallback that covers exactly what the question covers.
Search results and capsule entries name the collection they came from; the
images attached beside them carried only the chunk id and reference. Two
collections can return the same picture of the same document, so the two
labels were identical and the model could place neither.
The decision is the one already made for the text: `covers_multiple` at the
generic search tool, and the flag `search_corpus` computed for the capability
tools, which it now returns.
The collection block was chosen when the capability was built, so a run narrowed
through `state.sources` to one collection was still told how to attribute across
collections it could not reach, while its results correctly carried no
`Collection:` line.
`get_instructions` composes it per run instead, from `state.sources` where the
question narrowed the conversation and from the lent client or the scope
otherwise. Order is preamble, base instructions, collection block.
`covers_several_databases` had one line of body and two call sites.
`_db_path_given` guarded a default path manufactured in `__init__` and
overwritten in `__aenter__`; `_requested_db_path` is what the caller asked
for, and the effective path falls out of the resolved scope.
`resolve_db_path` manufactured the default path whenever `lancedb.databases`
was empty, and `covers_several_databases` read coverage back out of the
configuration, so a capability built without a client opened
`storage.data_dir/haiku.rag.lancedb` instead of what `lancedb.uri` placed.
The entry point resolves a `DatabaseScope` instead: instructions ask it what
it covers and `_ensure_rag` opens it through `HaikuRAG._covering`, so
coverage is decided once rather than encoded in a path and re-derived.
`Sandbox._covering` takes the scope the capability already resolved, beside
the public constructor that takes a path. The factory signatures are
unchanged.
A chunk id is unique within a database and says nothing across them, so a
database copied from another holds the same ids. `qualified_id` keys the
two in-memory identity sites on the database and the id together:
`merge_results` was dropping the second database's result when a query
repeated, and the arrival map that breaks fused score ties was ranking one
of the pair as the other.
Everything serialized records the id alone, so there ambiguity is refused
rather than qualified. `resolve_citations` raises `AmbiguousCitationError`
for a cited id held by two of the databases searched, where it used to
resolve to whichever result came last; `_register_citations` raises for one
already cited from another database in an earlier question. `_cite` turns
both into a `ModelRetry` asking for other evidence. The direct-id fallback
asks every database the question covers instead of taking the first that
answers, so an id no search returned is refused on the same terms.
`all_found` collects them and `first_found` reads its first, which document
reads keep doing on purpose.
Also drop a duplicated 0.77.0 heading from the changelog.
covers_multiple, source_names, source and reader_for replace the private
state seven modules were reading to work out how many databases they had.
The configured selection is kept intact, so entering a client twice derives
the same database rather than the last derivation.
`first_found` replaces the client's private version and the capability's
sequential one. The info modal reports through the connection the client
already holds.
A capability covering several databases received `source` on every document
and search result and never used it: asked how many documents were in each
database, the model read the titles and answered that there was one corpus
of 67,581. The instruction files enumerate what a result carries, and both
enumerations had gone stale.
The note follows what the capability opens rather than what the
configuration names, through `covers_several_databases`: an explicit
`db_path` or a lent client covering one database is instructed as before,
as is every `uri` or path deployment and every eval dataset. The analysis
note separates the three interfaces, since they differ: an
`analysis_search` result carries a `Database:` line, in-code `search` and
`list_documents` return `source`, and the mounted files carry neither.
Chunk 2 gave search a configured set to fan out over. ask and analyze
covered one database still: the RAG capability had no way to be told which
databases a question spanned, and the analysis sandbox mounted one
document tree.
The selection travels as sources on EvidenceState, beside the filter it
scopes with, so both capabilities read it the same way. clients_covering
is the one rule that turns a selection into clients, used by search, the
sandbox mount and the cite fallback, so a question scoped to some
databases cannot search, mount or cite another. Citations carry the
database they came from, and format_for_agent names it, so the model can
attribute evidence while it answers rather than only afterwards.
The sandbox keeps one flat /documents/{id}/ namespace and resolves each id
to the client holding it, which rests on ids being UUID4. A database
copied from another breaks that, so an id held twice is refused rather
than resolved to whichever arrived last.
On the CLI, search, ask and analyze cover the configured set and label
each result with its database. Every other command works on one, named
with --database NAME (a name reaches a database behind a URI, which --db
cannot) or --db PATH, and refuses a set it cannot choose from instead of
silently reading the default database. Cold databases open together, so a
first query costs the slowest open rather than their sum.
`ask(sources=[…])` scopes a question to some of the configured databases, carried
on the capability state so its search tool searches those. `Citation.source` names
the database a cited chunk came from, resolved from the search results the model
saw, which already carry it.
Context expansion routes each result through the database it came from: a
federating client has no repositories of its own.
The cite fallback, which looks up an id absent from this run's results, searches
only the selected databases. A chunk id says nothing about which database holds
it, so placing one means asking, and asking outside the selection would let a
question scoped to some databases cite another.
The loosely-specced client mocks in the capability tests now say they stand in for
a single-database client. A bare AsyncMock answers any attribute with a truthy
Mock, so `_federated` sent the fallback down the multi-database branch, and
`_source` reached a validated field.
The documented `HaikuRAG("knowledge.lancedb")` and `rag(db_path="my.lancedb")`
forms both raised `AttributeError: 'str' object has no attribute 'exists'`.
`Store.__init__` assigned its argument to a `Path`-annotated attribute without
coercing, and `resolve_db_path` returned a non-None argument unchanged. Every
runnable example wraps the path in `Path(...)`, which is why it survived.
Coerce in `Store.__init__` and `resolve_db_path`; widen the annotations on
`Store`, `HaikuRAG` and both `create_capability` factories to accept `str`.
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.
`client.ask` and `client.analyze` built their capability from a db_path, so the
capability opened a second connection to the database the client already had
open, once per call.
Ownership is now explicit rather than inferred. `rag` stays the connection the
capability opened and must close; `borrowed_rag` is a caller's, which
`_ensure_rag` prefers and `_close` never touches. Two fields rather than a flag,
so closing a borrowed connection is not expressible.
`for_run` still clears `rag` per run, since a run owns what it opens. It leaves
`borrowed_rag` alone: that connection belongs to the caller and outlives the run.
Both optional capabilities read what earlier questions retrieved and cited from
the capability's state, so a host that carries only the message history hands
every run an empty record. Compaction then replaced the earlier evidence with
receipts and retained nothing, and the loss was invisible: the citations the host
already displayed were still there. It now refuses when it finds evidence from an
earlier question and no record of what that question cited.
`state_carried` reaches the optional capabilities through discovery, so the
refusal distinguishes a host that never carries state from a question that simply
cited nothing.
The documentation taught the pattern that breaks: the compose example is now
stateful and the requirement is stated where each capability is introduced.
The app's browser storage was doing exactly this, keeping only the fields the UI
reads. It now persists the whole namespace map, so the citation policy's
violations survive a reload as well as the evidence record.
A cite call naming one good id and one mangled one registered the good one and
then asked the model to cite again. A model that mangles ids obeys, mangles
again, and the run dies on output retries with the answer lost: observed at 22
consecutive cite calls against gemma4-26b, with the citation policy registered
to press for a declaration. The branch is only reachable once something has
registered, so the answer already has grounding and there is nothing to ask for.
Resumption was inferred from the transcript, and no shape says it. A missing
prompt is how UI adapters ask their first question as much as how pydantic-ai
resumes one, so every AG-UI host failed on its first message. Reading the tail
instead moved the error rather than fixing it: a settled structured answer ends
with an output tool's return, indistinguishable from results delivered to a
question still in progress, so following questions inherited the first one's
identity.
`CapabilityEvidenceRecord.in_progress` is now the authority. `begin_question`
sets it, `after_run` clears it unless the run is only pausing for deferred work,
and a run that raised never reaches `after_run`, which is what leaves an
interrupted question claimable. The history is consulted only to catch a host
that dropped the state of a question the model is unmistakably still owed.
`rag_cite` and `analysis_cite` accepted only a non-empty `chunk_ids`, so a model with
nothing to cite could comply only by staying silent — indistinguishable from
forgetting. An empty list is now a valid answer to "what grounds this?", recorded as a
declaration with no refs, which derives `ungrounded` rather than leaving the question
undeclared. Citing again cannot narrow it: an empty call after a grounded one leaves
it grounded.
The instructions lose their carve-outs. Refusing for lack of information no longer
exempts the call, and a corpus-level computation cites an empty list instead of
skipping.
`_compact_old_tool_returns`, `PRIOR_TURN_NOTICE` and `turn_start` leave
`RAGCapabilityBase`, along with its `wrap_model_request` hook. The evidence
capabilities now retrieve and validate, and nothing else. Registering the compaction
capability is what rewrites a request; leaving it out sends the transcript untouched,
which was never a choice a host could make before.
The boundary is the recorded question identity rather than message shape, so a
resumption compacts what lies below the question in progress instead of switching
compaction off for the whole run. The newest earlier evidence return carries the
capsule and every other becomes a receipt, so one capsule exists by construction and
every return stays paired with its call.
Pictures of cited evidence are fetched through the capability that retrieved them and
re-attached beside the capsule with fresh labels. Ownership of a picture on the wire
requires the machine tag we write and an image directly after it, since neither
position nor prose is proof: several tools' results can arrive in one request, and a
user quoting our wording above their own picture had it removed. A picture that cannot
be fetched or decoded is emitted with neither its image nor its label.
The chat TUI and the example backend register the compactor, being multi-turn.
`client.ask`, `client.analyze` and the MCP tools do not: a single-shot question has
nothing earlier to compact.
`EvidenceCompactionCapability` reads what the evidence capabilities recorded out of
the run registry, and `build_capsule` renders it: every cited item, grouped by the
question that last cited it, newest group first, each rendered once, with the
pictures of cited evidence and the labels that must accompany them. Discovery runs
one way and reads only, so no capability holds a reference to another and a host
running one, both or neither needs no wiring change.
Everything cited is kept whole and everything else is dropped. There is no character
budget, no picture cap and nothing to configure: a cap would only half-rescue models
that fail on long conversations regardless, and a host that needs earlier evidence
pruned can compact its own requests further.
A capability reports which of its tools produce evidence, so a cite acknowledgement
is never mistaken for one. Pictures are identified by owner, document and reference,
so one figure cited through overlapping chunks is attached once while the same
reference in another document stays a different picture.
The builder does no I/O and never sees the message history, so a picture travels with
its label and the caller fetches the bytes. Nothing reaches the wire yet.
A question identity is unset until a run establishes it. Testing whether the
record exists cannot stand in for that: a host with no state to send seeds a
default record, and a default record is truthy, so a resumption missing its real
state would have proceeded as question zero.
Every way of recording a message count now refuses one behind what is already
stored, through one shared check: a new identity, an evidence outcome and a
declaration, each against the newest identity, evidence epoch and declaration
epoch. Identities and epochs are only comparable while the conversation grows,
and `before_model_request` results are assigned back onto history, so that is a
constraint on the host rather than a guarantee of the framework. Left unchecked,
an evidence outcome moving backwards freezes every later declaration as stale,
and a declaration moving backwards replaces a newer one with an older one and
revives the answer it grounded.
The searches, citations and executions of a question in progress survive a
resumption. Clearing them cost the results the model was still answering from: a
citation afterwards recorded no provenance and could not resolve against the
expanded result it had seen, falling through to a database lookup.
A code execution counts as evidence when it succeeded or printed something. A
raised error with an empty stdout grounds nothing.
`CapabilityEvidenceRecord` holds the relationships a transcript cannot express:
which chunks a capability retrieved, which it cited, in which questions, and at
which point in the conversation. RAG and analysis each own one in their own state
namespace. Nothing is co-written: the host's state is JSON storage, so a shared
record would be overwritten by whichever capability synced last, and merging
happens in transient per-request views instead.
Both clocks are derived from the conversation rather than counted locally, so
every participant computes the same values without sharing a counter. Question
identity is the message count when the question arrived; epoch is the message
count at an outcome. Epochs are therefore globally comparable, which is what
lets `citation_status` require a declaration to follow the newest evidence of
every capability, and what makes equal epochs mean one request.
A declaration is written only after `resolve_citations` succeeds, so a call
naming only unresolvable ids is not a citation. Status is derived, never stored,
so refs and status cannot contradict.
Resuming a question requires the host to carry the capability state from the run
being resumed. Without it the identity of the question in progress is unknowable,
and adopting the current message count would relabel that question as a new one
and judge every declaration in it against the wrong identity.
Nothing reads the records yet and no wire behaviour changes.
deferred_tool_results may arrive with a non-empty prompt, so the absence of a
prompt cannot be the only test for a resumption. Reproduced: resuming a live
question that way replaced its search result with the earlier-question notice
while the deferred result arrived alongside it.
_is_resumption accepts either signal — no prompt, or a history ending with a
request the model has not answered or a response whose tool calls have no
returns. A settled history ends with the previous answer, so a genuinely new
question is unaffected.
A new prompt on top of an unanswered tail is ambiguous and now counts as a
continuation: compacting costs the answer if it is one, while not compacting
only costs a larger request.
A run carrying no prompt is continuing a question rather than asking one:
pydantic-ai resumes that way for deferred tool results, interruptions and
suspended responses. len(ctx.messages) then counts the live question's own
messages, so its search result was replaced by the earlier-question notice and
the model was asked to answer with the evidence removed. Reproduced: resuming
with an in-flight history left a notice where the only evidence was.
Switch compaction off for the whole run when ctx.prompt is None. The absence of
a prompt is the signal rather than the message layout — the resume shapes differ
from each other, and deriving the boundary from layout is what broke this to
begin with.
ToolReturn.content reaches the model as a user-role message and the pictures
arrive bare, so nothing connects a figure to the chunk it came from:
BinaryContent.identifier does not survive serialization to the vision API, and
the captions in the result text correlate only by position.
Precede each picture with its position, source chunk id and self_ref.
build_binary_parts_from_results becomes build_image_content_from_results and
returns the labels interleaved with the pictures, so both attachment sites emit
them the same way.
This does not stop a model narrating retrieved pictures as user-supplied.
Measured on gemma4-26b with a single note ahead of the batch, and again with
per-image labels: it quotes the label and still says the user provided them.
The message role wins over its text.
_compact_old_tool_returns ran in before_model_request, whose result core
assigns back onto ctx.state.message_history, so the trim reached
all_messages() and every host that persists a thread. It now runs in
wrap_model_request, which operates on a detached list: the model sees the
trimmed history, the host keeps what it retrieved.
Content attached to a ToolReturn arrives as its own UserPromptPart in the
same ModelRequest as the ToolReturnPart, so the turn-boundary scan read an
image-bearing search result as a new user turn and discarded evidence
retrieved earlier in the same turn. _is_user_turn() now requires a request
with no tool returns.
Page images on a replaced return stay. Dropping them with their text bounds
context growth, but a follow-up about a figure already shown ("what colour
is that box?") carries no terms that could retrieve it again: measured on
gemma4-26b against two ORB figures, removing the image turned both answers
into "I cannot find enough information", and keeping it answers correctly.
Bounding that growth needs to preserve cited figures, which is a separate
change.
The replacement notice no longer claims citations remain in state;
_clear_invocation_state has cleared them by then.
The request-limit notice said only the cite tool remained available, but
chat registers rag and analysis in one agent, so exhausting analysis
claimed rag_search was gone too. Scoped to the capability's own tools.
The cite window was counted over every model request once loaded, so
turns spent on another capability expired it before the model was ever
placed where citing was the obvious move. Count only requests whose
preceding response called one of this capability's tools; engagement is
also the only thing that can loop, which is all the bound guards against.
Also: _count_tool_traffic returns a named tuple rather than four bare
ints, and counts failures only for this capability's tools, so host-tool
retries and output-validation retries no longer read as its failures.
The notice told the model to answer from what it had the moment
qa.max_searches ran out, while up to 15 code executions remained and
in-code search() does not count against that budget. It now names the
spent tool and points at whichever evidence tool still has budget,
falling back to answer-and-cite only when none do.
Also count RetryPromptPart in n_failed_tools: _cite rejects with
ModelRetry, so a run whose every cite attempt was refused reported zero
failures. And note that n_requests is the run's request count, which
tracks a capability's own budget only while it stays loaded.
- _budget_notice no longer names the cite tool after prepare_tools has
withdrawn it; the post-grace state gets the plain no-tools text back.
- Split search-budget rejections from any failed tool call: the code tool
raises ToolFailed for every error in model-written Python, so
budget_spent was true for a ZeroDivisionError.
- docs/capabilities/rag.md described the old single-turn removal.
- Drop the rationale clause from the CHANGELOG entry.