Commit graph

7 commits

Author SHA1 Message Date
Yiorgis Gozadinos
ddac328d05
Price qa.max_searches in search units
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.
2026-09-02 12:29:12 +03:00
Yiorgis Gozadinos
3ec72363bc
Let the record say whether a question is still being answered
Resumption was inferred from the transcript, and no shape says it. A missing
prompt is how UI adapters ask their first question as much as how pydantic-ai
resumes one, so every AG-UI host failed on its first message. Reading the tail
instead moved the error rather than fixing it: a settled structured answer ends
with an output tool's return, indistinguishable from results delivered to a
question still in progress, so following questions inherited the first one's
identity.

`CapabilityEvidenceRecord.in_progress` is now the authority. `begin_question`
sets it, `after_run` clears it unless the run is only pausing for deferred work,
and a run that raised never reaches `after_run`, which is what leaves an
interrupted question claimable. The history is consulted only to catch a host
that dropped the state of a question the model is unmistakably still owed.
2026-08-13 15:03:24 +03:00
Yiorgis Gozadinos
cee0824744
Say precisely which conversations go unenforced
The documentation claimed a conversation that has never cited anything is not enforced,
which reads as though a first question could answer from fresh evidence without
declaring it. Enforcement needs neither condition to hold: no evidence outcome in this
question, and nothing cited earlier.

The structured-output test accepted a redirect or a recorded violation, so a break in
ending detection would still have passed on the backstop alone. The cite tool is
available in that scenario, so it asserts the redirect.
2026-08-13 13:39:43 +03:00
Yiorgis Gozadinos
eb9934a6e5
Notice the endings a structured answer arrives in
An output tool call is a `ToolCallPart` like any other, and treating every tool call as
intermediate meant a model could search, skip citing, emit its structured answer and
finish with neither a redirect nor a record. A response ends the question when it
carries no tool calls, or when one of its calls names an output tool.

Some endings are not visible from a single response — a host running
`end_strategy="early"` can finish on text beside a function call — so `after_run` is
the backstop: it cannot ask the model for anything by then, but it records a question
that reached the end of its run undeclared. That also covers a question that was asked
once, ignored, and finished anyway, which previously returned early on the redirect
marker and went unrecorded.

The capability documentation and the changelog said a question that gathered no
evidence is left alone. That describes neither the code nor the intent: enforcement
applies wherever there is something to declare, which includes a follow-up that reuses
evidence cited earlier without searching again.
2026-08-13 13:39:43 +03:00
Yiorgis Gozadinos
a79f348ac7
Recognise our own redirect by a tag, not by its wording
`_already_asked` matched the redirect's phrasing, so a question that merely contained
it read as a redirect we had sent and switched enforcement off for that question. The
redirect carries `[haiku.rag/citation-redirect]` and detection matches that, the same
way retrieved pictures are identified by a tag rather than by the prose beside them.
2026-08-13 13:39:43 +03:00
Yiorgis Gozadinos
ae2755e88f
Enforce a declaration wherever there is something to declare
Requiring an evidence outcome from the current question exempted the case enforcement
exists for: a follow-up about evidence already cited needs no new search, since that
evidence is still on the wire — in a capsule when a compactor is registered, in full
when not. The condition is now that the conversation has something to declare, either
an outcome in this question or evidence it has already cited, which is independent of
whether anything compacts. A conversation that has neither is still left alone.

Citing again cannot narrow a question at any epoch. Declarations merged only within one
epoch, so an empty second thought a request later replaced the refs with nothing and
reported a grounded question ungrounded. They merge while no evidence outcome has
followed the standing declaration, and only genuinely newer evidence starts one afresh.

Whether a question has already been asked to declare is read from the message history
rather than remembered on the run instance, which a resumption's `for_run` discarded —
the same question was asked twice. Reading the history also makes the right call when a
redirect was enqueued but the run ended before it reached the model: nothing is in the
history, so it is asked again. Violations are recorded once per question for the same
reason.
2026-08-13 13:39:42 +03:00
Yiorgis Gozadinos
0f38417c60
Require an answer to declare what grounds it, once per question
`CitationPolicyCapability` makes the single enforcement decision, whatever mix of
evidence capabilities is registered: two of them must not each demand a citation for
one answer. It decides in `after_model_request`, when a response carries no tool calls
and the question can still be redirected. An explicitly ungrounded answer is a
declaration and is left alone; a question that gathered no evidence is left alone too,
read from the ledger rather than from a searches dict that a new question clears. When
the cite tool is already withdrawn the question is recorded in
`CitationPolicyState.violations` instead of pointing the model at a tool that is gone.

Registering it is the only switch. `DiscoveredEvidence` and discovery move to
`capabilities.evidence` so both optional capabilities share them, and `cite_available`
joins `evidence_tool_names` as public for the same reason.

Measured on Qwen3.6-35B over two arms of 37 questions, 29 of them unanswerable from
the corpus: explicit ungrounded declarations rose from 23 to 26, grounded answers to
unanswerable questions fell from 4 to 2, answerable questions stayed at 8 of 8, and
one redirect fired in the whole arm. Reading every answer found no invented grounding.
2026-08-13 13:39:42 +03:00