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.
54 lines
2.4 KiB
Markdown
54 lines
2.4 KiB
Markdown
# Citation policy capability
|
|
|
|
`CitationPolicyCapability` requires every answer to declare what grounds it. Citing is
|
|
always available and always recorded without it, but nothing makes the model do it.
|
|
|
|
Register it alongside an evidence capability:
|
|
|
|
```python
|
|
from pydantic_ai import Agent
|
|
from haiku.rag.capabilities.policy import create_capability as citation_policy
|
|
from haiku.rag.capabilities.rag import create_capability as rag
|
|
|
|
agent = Agent(
|
|
"openai:gpt-5",
|
|
capabilities=[rag(db_path="my.lancedb"), citation_policy()],
|
|
)
|
|
```
|
|
|
|
It exposes no tools and takes no configuration. Exactly one policy capability makes
|
|
the decision, however many evidence capabilities are registered, so two of them
|
|
cannot each demand a citation for one answer.
|
|
|
|
The host must carry the capability state between runs, alongside the message
|
|
history. Enforcement reads what the conversation has already cited, so without it
|
|
a follow-up about evidence cited earlier goes unenforced. See
|
|
[Compose an agent](index.md#compose-an-agent) for the shape.
|
|
|
|
## Declaring nothing is a valid answer
|
|
|
|
A model that finds nothing relevant calls the cite tool with an empty list. That records
|
|
the answer as *ungrounded*, which is distinct from an answer that declared nothing at
|
|
all (*missing*). The distinction is what makes a declaration requirable without forcing
|
|
the model to invent grounding.
|
|
|
|
## What happens when a question ends undeclared
|
|
|
|
The model is asked once to record what grounded the answer it already gave. It is not
|
|
asked to change the answer. If the cite tool is no longer available by then, or the
|
|
question finishes undeclared anyway, the question is recorded in
|
|
`CitationPolicyState.violations` under the `"citation_policy"` state key. Pointing a
|
|
model at a tool that is gone costs it retries, so the capability records the failure
|
|
instead.
|
|
|
|
## Which answers are enforced
|
|
|
|
Every answer in a conversation that has something to declare: either this question
|
|
retrieved evidence, or the conversation has already cited something, which stays
|
|
available to later answers. A follow-up about evidence cited earlier is enforced even
|
|
though it searched nothing, which is the case the capability exists for.
|
|
|
|
Once anything has been cited, later turns are enforced too, a greeting included. The
|
|
model satisfies the policy by citing an empty list, at the cost of one extra request. A
|
|
conversation with neither a current-question evidence outcome nor any earlier citation
|
|
is not enforced.
|