Commit graph

982 commits

Author SHA1 Message Date
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
Yiorgis Gozadinos
e1e7936d15
Let a model declare that nothing grounds its answer
`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.
2026-08-13 13:39:42 +03:00
Yiorgis Gozadinos
97a29d168f
Confine the ledger's epochs to the question that recorded them
A question's evidence epoch and declaration describe that question and end with
it, so `begin_question` clears them. Held past it, they let the last question's
citations ground the next one and hold a horizon its own declarations cannot
pass, and they force every question's message count to be comparable to counts
taken over a history that a host may since have rebuilt: a thread reconstructed
without a run that never finished shifts every later position, and the next
question was refused where answering it is correct.

Identities still separate one question from the next. Occurrences outlive the
question that recorded them and carry the identities that cited them, which the
capsule is grouped and ordered by, so reuse merges two questions into one group
and a lower identity renders later evidence as earlier.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
11b1bfbc94
Give the capsule to one return, and the run its own copy of state
A request can carry several of this capability's returns — a model can call search
twice in one response — and the carrier was identified by message alone, so each of
them received the whole capsule. It is selected by message and part now, so exactly
one carries it however many share the request.

The chat TUI passed its persisted state into the run, so tool synchronisation mutated
it in place while the message history was promoted only on success. A cancelled or
failed run therefore kept the evidence the tools had recorded and discarded the
messages that justified it, and the next question derived its identity from the
shorter history: behind the recorded epoch, refused as non-append-only, the
conversation unusable until cleared. The run gets a copy, promoted with the messages
or not at all.

Five decorators had been left attached to a helper by an earlier extraction, which
pytest does not collect, so the resume case they carried was silently untested. The
wire test covers both resume shapes again, no prompt and deferred results.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
2cd568847e
Move the wire rewrite into the compaction capability
`_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.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
0aa6d79f88
Build one capsule of cited evidence from the records
`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.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
42d923fe4b
Make the ledger's clocks defensible against the host
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.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
85594a1fd1
Record what each evidence capability retrieved and cited
`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.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
fa0c4a4f50
Treat an unfinished history tail as a continuation too
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.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
8eef0b3734
Leave a resumed run's evidence alone
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.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
46f7ab8d97
Name the search result each page image belongs to
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.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
d9bd3a701f
Take the compaction turn boundary from the run, not the message shape 2026-08-13 12:59:22 +03:00
Yiorgis Gozadinos
5d76461b9f
Trim earlier-question evidence off the wire, keeping the images
_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.
2026-08-13 12:59:22 +03:00
Yiorgis Gozadinos
af77f836d5
Reuse the local docling converter across documents 2026-08-13 11:39:01 +03:00
Yiorgis Gozadinos
6186dae83f
Move ingester telemetry setup behind argument parsing 2026-08-13 11:37:36 +03:00
Lawrence Akka
d4d6414bc4 Similar fix for haiku.rag.ingester, and formatting
- Add regression tests
- Update Changelog
2026-08-12 15:20:11 +01:00
Lawrence Akka
5e6284fd02 Stop eagerly importing Store from haiku.rag.store
Import Store from haiku.rag.store.engine directly instead of re-exporting it
through the package __init__, and defer HaikuRAGApp's import in cli.py behind
TYPE_CHECKING/lazy imports, avoiding an eager import at CLI startup.
2026-08-11 17:26:51 +01:00
Yiorgis Gozadinos
d63199d96d
Keep cross-encoder rerank scores apart when they saturate
mxbai-rerank-base-v2 ships a Sigmoid activation and evaluates it in bf16, so
every strongly-relevant candidate rounds to exactly 1.0. Ties then leave the
order to the stable sort, which preserves the incoming hybrid ranking: on 100
t2_finqa retrieval cases the reranker scored MAP 0.661 against 0.659 with no
reranker at all, and 0.742 once the scores separate.

Ask the model for logits and apply the sigmoid here, where it runs in float64.
Scores stay 0-1, matching the cohere, vllm and zeroentropy rerankers.

Also drop the remaining pyright references; the project type-checks with ty.
2026-08-07 14:07:53 +03:00
Yiorgis Gozadinos
eb11a165b8
Stop loading page rasters on the title and update paths 2026-08-06 13:04:51 +03:00
Yiorgis Gozadinos
ac9b2cbf81
Load document blobs only when asked 2026-08-06 13:04:51 +03:00
Yiorgis Gozadinos
292835190a
Classify obstore config errors as permanent 2026-08-06 12:53:45 +03:00
Yiorgis Gozadinos
8b45f81464
Load only content when listing documents with include_content 2026-07-31 10:12:19 +03:00
Yiorgis Gozadinos
c137305468
Scope the limit notice and spend the cite window on own turns only
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.
2026-07-30 19:14:14 +03:00
Yiorgis Gozadinos
27ed0b3bb3
Send analysis to the sandbox when only its search budget is spent
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.
2026-07-30 18:17:26 +03:00
Yiorgis Gozadinos
721acbcf38
Address review on PR #524
- _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.
2026-07-30 15:50:06 +03:00
Yiorgis Gozadinos
68238f0b6a
Name the readlines workaround when sandbox code iterates a file
50 executions across 40 cases in an 822-case run died on
'_io.TextIOWrapper' object is not iterable, and those cases scored 37.5%
judged against 60.1% and cited 12.5% against 55.2%.
2026-07-30 14:56:17 +03:00
Yiorgis Gozadinos
5752f61f2c
Recover chunk ids mistyped from search results
Resolve a cited id that misses exactly to the nearest id the run
retrieved, above a 0.75 similarity cutoff.
2026-07-30 14:55:43 +03:00
Yiorgis Gozadinos
685a7c393d
Keep the cite tool past a capability's request limit 2026-07-30 14:55:08 +03:00
Yiorgis Gozadinos
e2b930d469
Use a neutral dotfile in the docling name examples
.gitignore reads as this repository's own file in the helper docstring,
the tests and the changelog entry.
2026-07-30 14:13:23 +03:00
Yiorgis Gozadinos
8e7e494fd1
Strip leading dots from names handed to docling 2026-07-30 14:07:00 +03:00
Yiorgis Gozadinos
d1ad14f44b
Point the limit comments at the right limits
_session_limits still called the session budget the backstop for code that
computes, which stopped being true when the pool gained a request_timeout in the
same commit. The watchdog kills such a call at code_timeout, so it can never
spend a budget of code_timeout * max_executions. The docstring now names the two
per-call limits and claims neither.

The crash test said the discard prevents a RuntimeError escaping execute(). The
handler catches RuntimeError too, so without the discard every later call
returns a failed result instead.

Name MemoryFile's missing write hook as the reason metadata.json takes a reader
and a deny pair. Drop the clause in the CHANGELOG that narrated how the old
overrun accumulated.
2026-07-28 19:11:37 +03:00
Yiorgis Gozadinos
221c90af72
Bound a compute-only call at code_timeout
The pool watchdog counts only the time a worker spends running code, so a read
that blocks the worker never trips it. The margin above code_timeout therefore
guarded a race that cannot happen, and only bought a runaway call 90s where 60s
was configured. Pass code_timeout straight through. The two limits are disjoint:
the watchdog bounds a call that computes, and the read deadline bounds a call
that reads.

Drop the ordering test, which was true for any positive margin. The containment
test no longer sets code_timeout to zero, because that value now also disables
the watchdog and kills the worker before the read guard can refuse. It patches
the guard instead.

Monty 0.0.19 runs a plain class and a class with __enter__ and __exit__. Only
inheritance and metaclasses raise. Say that in the instructions.

MemoryFile is no longer constructed, so drop the import, the annotation, and the
stale references in the sandbox docstring and CLAUDE.md. That CLAUDE.md line
also still claimed a ThreadPoolExecutor, _run_async, and a fresh interpreter per
call.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
d9e4d1e57c
Bound a call that never reads and restore the iteration test
The read deadline only gets control at a read, so code that computes without
reading escaped it. Give the pool a request_timeout above code_timeout. The
watchdog kills the worker, and execute() already replaces a dead session, so a
runaway call fails and the next call recovers. A read refusal keeps the
variables, so it has to win the race whenever code does read.

Restore test_open_file_objects_are_not_iterable. Replacing the neighbouring
write test by text range deleted it, which left the instructions carrying a
prohibition with nothing to signal when monty lifts it.

Reuse the VFS and the pool when a session is replaced, so recovery skips the
document scan. Mount metadata.json with a lambda rather than a factory. Cover
open() in write mode. Fold the crash entry into the pydantic-monty bullet,
because no release shipped the worker without it.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
94befc0dfd
Recover from worker death and deny metadata writes
A crashed worker used to poison the rest of the run. execute() reported the
crash as a failed result, but kept the dead session, and every later call then
raised RuntimeError out of the tool. Clear the session so the next call checks
out a replacement. Keep the session for a syntax or runtime error, which leaves
the worker healthy, and say in the failure text that a restart loses the
variables.

A MemoryFile accepts writes, so metadata.json took them while the other three
document files refused. Mount it through the same read and deny pair. The
write-denial test now covers all four files.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
522959d9b4
Check the code timeout before each document read
The VFS bridge suspends the Monty worker for the length of a read. Monty checks
its duration budget between interpreter steps, so it cannot check while a read
is in flight. Code that reads in a loop overran a 60s budget by minutes. A read
takes about 20ms on a 2789-document corpus, so a full scan spends about 55s in
reads alone.

Check the deadline before each read. Raising from inside the callback answers the
worker's suspension, which keeps the session usable.

Monty also spends max_duration_secs across the session rather than per call, and
the sandbox reuses the session so that variables persist. Budget it for
code_timeout * max_executions. At the old per-call value the first slow call
starved every later one.

Do not wrap feed_run in asyncio.wait_for. Cancelling during pure compute is
clean, but cancelling while a read waits for an answer wedges the session with a
protocol RuntimeError that escapes execute(). A call that computes without
reading stays bounded by the session budget alone.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
feaca386d3
Document that file objects cannot be iterated
Monty 0.0.19 supports open() and with blocks, but a file object is still not
iterable. See pydantic/monty#490, which is still open. The instructions now
state the limitation in three places and give the alternative next to each
one: readlines() or read().split("\n").

Replace chr(10) with "\n" in the prose and in the example. Both work on
0.0.19, and chr(10) implies that the escape is broken.

Add a test that pins the limitation. The test fails when Monty gains
iteration support, which is the signal to relax the instructions.
2026-07-28 19:10:53 +03:00
Yiorgis Gozadinos
5c8df37af1
Support open()/with in the analysis sandbox
Document files can be read with open() and with-blocks (.read(),
.readline(), .readlines()); writes raise PermissionError. File objects
remain non-iterable and the collections module is still unavailable.
2026-07-28 19:10:53 +03:00
Yiorgis Gozadinos
53084d6fdd
Bump pydantic-monty to 0.0.19
0.0.19 runs sandboxed code in a subprocess worker pool (AsyncMonty /
AsyncMontySession) and drops MontyRepl. The sandbox checks out a session,
drives it with feed_run, and closes it to return the worker; close() is
now async. Worker crashes surface as a failed SandboxResult. The document
VFS (OSAccess/CallbackFile/MemoryFile) is unchanged.
2026-07-28 19:10:53 +03:00
Yiorgis Gozadinos
f13a3fb677
Report tool failures with ToolFailed 2026-07-27 18:26:42 +03:00
Yiorgis Gozadinos
ae345cc39f
Map thinking onto Pydantic AI's unified setting 2026-07-27 18:26:42 +03:00
Yiorgis Gozadinos
04ad3823b9
Cover the lazy config init and restore the uv cache in CI
get_config() builds its instance on first use, so the branch was covered
only when a worker happened to call it before set_config(). Under xdist that
depends on how cases shard across workers, which varies with the core count:
covered locally, uncovered on the two-core runner. Assert it directly.

setup-uv v4 bundles a cache client the GitHub cache service now rejects with
400, so the uv cache never restored and every wheel was redownloaded. Bump
to v9 across all four workflows (build-docs was already drifting at v5).

setup-python read requires-python (">=3.12") and installed 3.14, which uv
then ignored in favour of .python-version (3.13) and downloaded itself.
Point it at .python-version so the interpreter it installs is the one used.
2026-07-27 13:35:50 +03:00
Yiorgis Gozadinos
ab88188529
Keep the lazy-read test off the embedder
Rewriting the document through client.update_document re-chunks and
re-embeds, so the test needed an embedding endpoint that CI does not have.
Write the row through the repository instead, matching how the rest of the
file avoids the embedder.
2026-07-27 13:17:05 +03:00
Yiorgis Gozadinos
a602e0fbf5
Test vacuum against real table state 2026-07-27 12:11:01 +03:00
Yiorgis Gozadinos
f96a428ef1
Fix defects found reviewing the coverage work
check_source_accessible narrowed its handler to ValueError, but Path.exists
re-raises errno values outside its ignored set (EACCES, ENAMETOOLONG). Those
were swallowed before and now escaped into the rebuild sweep the guard exists
to protect. Catch OSError too.

Restore the arity guard in _common_path_prefix: without it an empty list
raises from min() and a single label yields a prefix covering the whole path.

Two tests would have hung rather than failed on regression (the vacuum skip
and the protected-wait cancellation); both are now bounded. The import
vacuum test raced against the done-callback that discards the task, and now
spies on the call instead, with a negative control.

Replace assertions that could not fail: blank-query search against an empty
corpus, a batch flush counted against an empty table, a picture description
asserting its own input state, and an FS scheme check with nothing on disk to
resolve. The get_model matrix asserted only the returned type across 26
cases and now pins the per-provider settings. The three batching tests now
count flushes, which revealed embed-only writes through chunks_table.add
rather than _flush_rebuild_batch.
2026-07-27 10:44:32 +03:00
Yiorgis Gozadinos
7ba78fdde3
Normalize pragma comments to the codebase's single-line form 2026-07-26 20:18:19 +03:00
Yiorgis Gozadinos
f6acb65e95
Reach and enforce 100% coverage
Cover the remaining paths in the client, context, downloads, title
generation, document tools and store models, and add fail_under=100 so
uncovered lines fail CI.

Six lines that no test can reach get a pragma with its reason: the docling
import guard, the nameless PDF attachment, the FS symlink OSError guard that
resolve(strict=False) absorbs, the docling bbox and LanceDB document-id
shape guards, the tag-retention branch vacuum makes unreachable, and Monty's
Rust-thread print callback.

Fix test_find_config_file_user_config, which wrote its config into the cwd it
had chdir'd to, so the cwd branch answered first and the user-directory
lookup it names was never exercised.
2026-07-26 20:11:02 +03:00
Yiorgis Gozadinos
7c120587f0
Cover sandbox VFS reads, binary part dedup and picture spans 2026-07-26 19:41:09 +03:00
Yiorgis Gozadinos
2afe1bd28c
Cover store engine and repository paths
Add vector-index creation tests including the warned failure, chunk
repository get_by_id, list_all pagination, blank-query and precomputed-vector
search, the unknown-score-column guard, settings row recreation, and
replace_for_document with no items.
2026-07-26 19:36:37 +03:00
Yiorgis Gozadinos
dc5ad8f699
Cover the rebuild paths
Add tests for the vacuum-failure warning, documents deleted mid-rebuild,
chunkless documents, batch flushes in the embed-only, descriptions and full
paths, a missing docling blob under rechunk, missing and recoverable picture
bytes, and the source-missing fallback. Direct-call tests cover the staging
helpers and the idempotent phase-1 marker.
2026-07-26 19:32:09 +03:00
Yiorgis Gozadinos
7c76c3399e
Cover get_model provider branches and visualize_chunk edge paths
Parametrize the get_model tests whose only assertion was the returned model
type, extending the table to the reasoning-off, groq-parsed and Bedrock
o-series/qwen/unmapped branches. Fold format_citations_no_title into the
superset test that already covered its scenario.

Add tests for cosine_similarity on parallel vectors, multi-citation Rich
rendering, picture rendering with missing and undecodable bytes, a missing
docling distribution, and the visualize_chunk short-circuits for absent
documents, absent rasters and refless chunks.
2026-07-26 19:23:54 +03:00
Yiorgis Gozadinos
1e8e5e9f6f
Cover MCP, ingester and converter error paths
Add tests for the MCP tools' degradation contracts, malformed WebDAV
multistatus bodies, dry-run poller sweeps including the circuit-open and
discover-failure paths, FS source scheme and symlink handling, docling-serve
zip parsing, and the remaining embedding and reranker helpers. Parametrize
_strip_etag.

Drop the misplaced pragma on the analyze handler, which sat on the return and
left the except uncovered. Add one on the FS symlink OSError guard, which
resolve(strict=False) absorbs for every real link.
2026-07-26 19:14:26 +03:00
Yiorgis Gozadinos
dddaf0f84c
Remove unreachable code and fix two defects it surfaced
Delete repository methods with no callers (SettingsRepository CRUD,
ChunkRepository.update/delete/get_chunks_in_range), the DataFrame branch of
_process_search_results whose only caller always passes a query, and guards
that cannot be reached from their call sites: the rebuild mode=None default,
the staging drop already performed by _resolve_rebuild_recovery, the empty
batch skip, two context fast paths, the doctor prefix guard, the poller
_task attribute that is never assigned, and a docling caption fallback for
a field name no item class defines.

set_haiku_version built a recreated settings row from the process-global
Config rather than the store's own, so a store opened with a custom config
stamped global settings into the database.

check_source_accessible called urlparse outside its try block, so a stored
URI with a malformed IPv6 host raised ValueError instead of reporting the
source as inaccessible, aborting the whole rebuild sweep.
2026-07-26 18:45:48 +03:00
Yiorgis Gozadinos
e2b273ee2a
Consolidate duplicated client-side tests
Parametrize sibling tests that differed only in a literal value, and fold
two strict-subset tests into the survivors that already covered their
scenario. Every case that ran before still runs; the union of assertions
is applied to each case, strengthening list_all, get_pages_data and
resolve_doc_items.

Replace four hand-rolled log-capture handlers with a shared
capture_logs() contextmanager in conftest.

13 fewer test functions, 348 fewer lines.
2026-07-26 13:30:59 +03:00
Yiorgis Gozadinos
c716be9640
cover the mcp path with no images 2026-07-25 15:00:36 +03:00
Yiorgis Gozadinos
cdaeaa94e6
Constrain the chat prompt container height 2026-07-25 14:40:40 +03:00
Yiorgis Gozadinos
93824cdee5
Surface database-open errors in the TUIs
ChatApp and InspectorApp assigned self.client before __aenter__
completed, so a failed open was masked by an AttributeError from
on_unmount tearing down the never-opened client.
2026-07-25 11:27:29 +03:00
Yiorgis Gozadinos
5f4c73f89f
Add image attachment to the chat TUI 2026-07-25 10:58:08 +03:00
Yiorgis Gozadinos
c62fd78c7a
Accept images on CLI ask/analyze and MCP tools 2026-07-25 10:35:25 +03:00
Yiorgis Gozadinos
4c5050d161
Accept images on ask/analyze 2026-07-25 10:23:55 +03:00
Yiorgis Gozadinos
55351a8829
Merge leading system messages for OpenAI-compatible endpoints 2026-07-24 16:57:55 +03:00
Yiorgis Gozadinos
f87fabe556
Make the capability image-attachment gate a vision bool 2026-07-24 16:00:01 +03:00
Yiorgis Gozadinos
6c5bc0aae1
Gate combined-chat capability vision on the driving model 2026-07-24 15:27:20 +03:00
Yiorgis Gozadinos
597808c56e
Fix chat analysis-model selection, AG-UI example state, and chat docs
Drive analysis-only chat with analysis.model (falling back to qa.model) so
the running model matches the one the analysis capability configures,
including its vision flag; RAG-bearing chats still run on qa.model.

Give the AG-UI example state-bearing deps and a final STATE_SNAPSHOT so
registered citations reach the client, mirroring the app backend.

Update chat docs: haiku-rag chat uses --capability/-c, and drop the removed
"View state" command-palette entry.
2026-07-24 15:27:20 +03:00
Yiorgis Gozadinos
b8dcb066dc
Port cite partial-success feedback to capabilities 2026-07-24 15:27:19 +03:00
Yiorgis Gozadinos
175929f23a
Improve coverage 2026-07-24 15:26:18 +03:00
Yiorgis Gozadinos
77585ef26a
fix per-question capability limits and tool isolation 2026-07-24 15:26:18 +03:00
Yiorgis Gozadinos
43c17a6777
fix capability execution limits and chat loading 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
22ffd5e92e
Add max retries=3 back 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
9deb1f2bd4
replace haiku.skills with native Pydantic AI capabilities 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
35b8b046ce
Cover the detached-chunk guard in the multimodal rerank search test 2026-07-24 13:07:39 +03:00
Yiorgis Gozadinos
543aba7547
Multimodal reranking: send picture chunks to vllm rerankers as images
reranking.multimodal (vllm provider only) attaches picture bytes to
synthetic picture chunks before rerank; VLLMReranker sends them as
content-parts documents (base64 data URI + description text) in the
same /v1/rerank request as plain text documents.
2026-07-24 12:29:20 +03:00
Yiorgis Gozadinos
0308220769
Read verbs skip the embeddings config-compatibility check 2026-07-24 12:14:31 +03:00
Yiorgis Gozadinos
d9cc9a4eea
Narrow AutoTokenizer type for transformers 5 2026-07-23 12:01:28 +03:00
Yiorgis Gozadinos
75a21c82c2
Search results and citations carry document metadata 2026-07-23 10:23:48 +03:00
Yiorgis Gozadinos
8bf569ef89
cite reports unresolvable chunk ids on partial success 2026-07-22 11:56:56 +03:00
Yiorgis Gozadinos
3034280c78
Skip embedding validation on document deletion 2026-07-22 10:48:00 +03:00
Yiorgis Gozadinos
1ed424b50d
Serialize metadata-only document updates with the write lock 2026-07-16 13:11:35 +03:00
Yiorgis Gozadinos
f02cde5ddf
Fix same-tick cancellation losing recovery results in _wait_protected 2026-07-16 13:11:35 +03:00
Yiorgis Gozadinos
dd2817ff6d
Protect tag-creation cleanup from cancellation 2026-07-16 13:11:35 +03:00
Yiorgis Gozadinos
1f88944ada
Protect restore rollback from cancellation; report delete_tag listing failures 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
ce1b9ac88e
Harden history against tag annotation failures; document tag restore 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
5453c00c95
Test restore and migration separation 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
4522fbdf1b
Add tag restore CLI 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
f41ec379df
Add Store.restore_tag 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
a1f3435df3
Vacuum suppresses OSError only 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
f997d7adc9
Consolidate tag primitives 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
0e271eaf4b
Remove --before/--at time travel 2026-07-16 13:11:33 +03:00
Yiorgis Gozadinos
0813a1c980
Add tag CLI commands and history tag annotations 2026-07-16 13:11:27 +03:00
Yiorgis Gozadinos
0cbde6b7a2
Serialize tag operations, vacuum, and metadata refresh with writes 2026-07-16 12:32:57 +03:00
Yiorgis Gozadinos
69319c9390
Grow vacuum retention to protect tagged versions 2026-07-16 12:32:57 +03:00
Yiorgis Gozadinos
ae603d9b4d
Add database-level tag primitives to Store 2026-07-16 12:32:56 +03:00
Yiorgis Gozadinos
aa620aeb60
Bump lancedb to 0.34.0 2026-07-16 12:32:56 +03:00
Yiorgis Gozadinos
d2f5ecfc58
Prevent magic-byte sniffing from misrouting text conversion in docling-local
Docling guesses a DocumentStream's format from its first bytes before
considering the extension, so markdown/HTML content starting with a binary
magic signature ("BM" = BMP, "ID3" = MP3) was routed to an image or audio
backend and fell back to plain-text conversion. Prefix the encoded text
with a newline so the sniff finds nothing and the extension decides.
2026-07-15 19:20:54 +03:00
Yiorgis Gozadinos
144900d385
Remove the mxbai reranking provider 2026-07-14 11:09:55 +03:00
Yiorgis Gozadinos
5e2a928013
Allow transformers 5.x in the mxbai extra 2026-07-14 11:09:55 +03:00
Yiorgis Gozadinos
82db15cf3c
Fill missing item-table pages from fully-visible inputs in context expansion 2026-07-14 10:32:45 +03:00
Yiorgis Gozadinos
ee5b7b7313
Split merged context-expansion groups that cannot afford every constituent 2026-07-14 10:15:28 +03:00
Yiorgis Gozadinos
271fdf9b5a
Add Logfire debugging skills and worker-breaker event 2026-07-10 13:23:17 +03:00