Guard dedup against compaction and document search units

A picture deduplicated within a burst must still reach the model once in
its own question and, when cited, again via the capsule re-fetch after
compaction; uncited it is dropped. Two wire tests pin both paths. The
harness builds the rag capability with defer_loading=False: a deferred
evidence capability the model has not loaded presents an empty record,
so compaction computes boundary 0 and rewrites nothing.

qa.md describes max_searches in units.
This commit is contained in:
Yiorgis Gozadinos 2026-09-02 12:20:33 +03:00
parent d9f489dcc8
commit 31fc8aba29
No known key found for this signature in database
2 changed files with 85 additions and 2 deletions

View file

@ -30,12 +30,12 @@ qa:
enable_thinking: true
temperature: 0.3 # Default: 0.3
vision: false # Set true for vision-capable models
max_searches: 5 # Maximum search tool calls per question
max_searches: 5 # Maximum search units per question
```
- **model**: LLM configuration (see [Providers](providers.md#model-settings))
- **model.vision**: Set to `true` for vision-capable models (`qwen2.5vl`, `qwen3.6`, `gpt-4o`, `claude-sonnet`, …). The capability's `search` tool only attaches picture bytes (`BinaryContent`) to its `ToolReturn` when this is `true`, otherwise picture bytes are withheld. See [Pictures × embedder × QA model](processing.md#pictures-embedder-qa-model-how-the-pieces-compose) for the full matrix.
- **max_searches**: Maximum number of search tool calls a capability can make per question (default: 5). Shared by the RAG and analysis capabilities.
- **max_searches**: Maximum number of search units a capability can spend per question (default: 5). Up to three searches emitted in the same model response share one unit, so a model that rephrases its query in one response spends one unit. A search in a later response starts a new unit, as does each further group of three within one response. Shared by the RAG and analysis capabilities. Searches in one response also deduplicate their returns: evidence a sibling search already showed collapses to a reference line, and each picture attaches once per response.
!!! note "Thinking on vLLM"
`enable_thinking` only applies to models with a pydantic-ai reasoning profile (o-series, gpt-5, gpt-oss). For other vLLM-served models such as Qwen3 or the Gemma family, the field is a silent no-op — set the chat template switch via [`extra_body`](providers.md#raw-provider-pass-through) instead.

View file

@ -517,6 +517,89 @@ async def test_a_picture_that_will_not_decode_emits_neither_image_nor_label(
assert texts_of(wire[-1]) == []
def _burst_result() -> SearchResult:
return SearchResult(
content="evidence",
score=1.0,
chunk_id="chunk-1",
document_id="doc-1",
source="main",
doc_item_refs=["#/pictures/0"],
image_data={"#/pictures/0": base64.b64encode(REAL_PNG).decode()},
)
async def _fanout_question_then_another(temp_db_path, cite: bool) -> list[list[Any]]:
"""Question 1 fans out over one picture chunk; question 2 follows compacted."""
rag = create_rag(
db_path=temp_db_path, config=AppConfig(), defer_loading=False, vision=True
)
client = AsyncMock()
client.search.side_effect = [[_burst_result()], [_burst_result()]]
client.expand_context.side_effect = lambda results: results
client.source_names = ["main"]
rag.borrowed_rag = client
citing = (
[[ToolCallPart("rag_cite", {"chunk_ids": ["chunk-1"]}, "call-3")]]
if cite
else []
)
calls = iter(
[
[
ToolCallPart("rag_search", {"query": "figure"}, "call-1"),
ToolCallPart("rag_search", {"query": "the figure"}, "call-2"),
],
*citing,
[TextPart("first answer")],
[TextPart("second answer")],
]
)
wire: list[list[Any]] = []
async def model(messages, _info):
wire.append(list(messages))
return ModelResponse(parts=next(calls))
agent = Agent(
FunctionModel(model),
deps_type=Deps,
capabilities=[rag, create_compaction()],
)
deps = Deps()
with patch.object(
RAGCapability, "get_picture_bytes", AsyncMock(return_value=REAL_PNG)
):
first = await agent.run("what does the figure show?", deps=deps)
await agent.run(
"and what else?", deps=deps, message_history=first.all_messages()
)
return wire
@pytest.mark.asyncio
async def test_a_burst_deduplicated_picture_survives_compaction_when_cited(
temp_db_path,
):
"""Dedup attaches the picture once in its own question; the capsule re-fetches
it for the next. Neither pass may leave the model without it."""
wire = await _fanout_question_then_another(temp_db_path, cite=True)
assert [picture.data for picture in images_of(wire[1])] == [REAL_PNG]
assert [picture.data for picture in images_of(wire[-1])] == [REAL_PNG]
@pytest.mark.asyncio
async def test_a_burst_deduplicated_picture_is_dropped_by_compaction_uncited(
temp_db_path,
):
wire = await _fanout_question_then_another(temp_db_path, cite=False)
assert [picture.data for picture in images_of(wire[1])] == [REAL_PNG]
assert images_of(wire[-1]) == []
@pytest.mark.asyncio
async def test_the_capsule_is_built_once_per_request_and_again_for_the_next(
temp_db_path,