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.
This commit is contained in:
Yiorgis Gozadinos 2026-07-30 18:50:05 +03:00
parent 27ed0b3bb3
commit c137305468
No known key found for this signature in database
7 changed files with 148 additions and 79 deletions

View file

@ -12,12 +12,13 @@
### Fixed
- A failed `analysis_execute_code` call that iterated a file object reports the `.readlines()` workaround alongside the `TypeError`.
- A capability that reaches its request limit keeps its cite tool for two further model requests while its other tools are removed.
- A cited chunk id that does not match a retrieved id exactly resolves to the closest one above a 0.75 similarity cutoff, recovering ids mistyped from search results.
- A capability that reaches its request limit keeps its cite tool for two further requests that call one of its tools, while its other tools are removed.
- A cited chunk id that does not match a retrieved id exactly resolves to the closest one above a 0.75 similarity cutoff.
- Dotfiles are parsed as their actual format instead of a single unstructured text block. Docling ignores the extension of a name starting with a dot, so converters strip leading dots from the name they hand it.
### Documentation
- `qa.max_searches` is documented as defaulting to 5 in `docs/configuration/qa.md` and `docs/configuration/index.md`, was 3.
- New "Vacuum Memory Requirements" subsection in `docs/configuration/storage.md` documenting the ~5x peak memory of vacuum compaction relative to the `documents` table, the mitigations, and upstream issue lancedb/lancedb#2325.
## [0.71.0] - 2026-07-29

View file

@ -4,7 +4,7 @@
It is deferred by default, keeping its substantial instructions and tool schemas out of context until the model chooses to load it.
The default request limit is 30 model requests per question. Override it with `create_capability(request_limit=...)`, or set `request_limit=None` to disable it. As with the RAG capability, `create_capability(vision=...)` overrides the image-attachment gate, defaulting to the configured analysis model's `vision` flag. At the limit, `analysis_search` and `analysis_execute_code` are removed while `analysis_cite` remains for two further requests, so the model can register citations before answering from gathered evidence. Other agent and capability tools remain available, and the budget resets for every agent run.
The default request limit is 30 model requests per question. Override it with `create_capability(request_limit=...)`, or set `request_limit=None` to disable it. As with the RAG capability, `create_capability(vision=...)` overrides the image-attachment gate, defaulting to the configured analysis model's `vision` flag. At the limit, `analysis_search` and `analysis_execute_code` are removed while `analysis_cite` remains for two further requests that call an analysis tool, so the model can register citations before answering from gathered evidence. Requests spent on other capabilities do not count against that window. Other agent and capability tools remain available, and the budget resets for every agent run.
When `qa.max_searches` or `analysis.max_executions` runs out, the exhausted tool keeps failing rather than disappearing, and the instructions name it on every following request. Searching from inside `analysis_execute_code` does not count against `qa.max_searches`.

View file

@ -26,7 +26,7 @@ print(result.output)
`create_capability` accepts `db_path`, `config`, `defer_loading`, `request_limit`, and `vision`. Set `defer_loading=False` for a dedicated RAG agent where routing is unnecessary. The default request limit is 20 model requests per question; set `request_limit=None` to disable it. `vision` controls whether picture results are attached to search returns as images and should reflect the model the hosting agent runs; it defaults to the configured QA model's `vision` flag.
When the limit is reached, `rag_search` is removed while `rag_cite` remains for two further requests, so the model can register citations before answering from evidence already gathered. Unrelated agent and capability tools remain available. A new agent run starts a fresh limit, so multi-turn chat does not consume one shared budget.
When the limit is reached, `rag_search` is removed while `rag_cite` remains for two further requests that call a RAG tool, so the model can register citations before answering from evidence already gathered. Requests spent on other capabilities do not count against that window. Unrelated agent and capability tools remain available. A new agent run starts a fresh limit, so multi-turn chat does not consume one shared budget.
## State

View file

@ -1,7 +1,7 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol, cast
from typing import Any, NamedTuple, Protocol, cast
from pydantic_ai import Agent
from pydantic_ai.messages import (
@ -42,32 +42,32 @@ class CapabilityRunResult:
n_requests: int = 0
class ToolTraffic(NamedTuple):
n_search_calls: int
n_rejected_searches: int
n_failed_tools: int
n_requests: int
def _count_tool_traffic(
messages: list[ModelMessage], namespace: str
) -> tuple[int, int, int, int]:
messages: list[ModelMessage], namespace: str, tool_names: frozenset[str]
) -> ToolTraffic:
"""Count search calls, failed calls and model requests in a run.
``state.searches`` is keyed by query, so it collapses repeated queries and
never records a call the capability refused. The capability object cannot be
read instead: ``for_run`` hands the run a ``replace()`` copy, so the outer
instance's counters stay at zero. Counting the message history is the only
way to see the real number of attempts.
The history is the only source: ``state.searches`` is keyed by query so it
hides repeats and refusals, and ``for_run`` hands the run a ``replace()``
copy, leaving the outer capability's counters at zero.
Search failures are counted apart because the search tool fails only when
its budget is spent. A failed code call is ambiguous an exhausted
execution budget and any error in model-written Python both surface as
``ToolFailed``, distinguishable in the history only by message text so
``n_failed_tools`` covers both without claiming to tell them apart.
Only search failures mean an exhausted budget. A failed code call may be
either the execution budget or any error in model-written Python, so
``n_failed_tools`` covers both without claiming to tell them apart. It counts
``RetryPromptPart`` too, since ``_cite`` rejects with ``ModelRetry`` and only
``ToolFailed`` sets ``outcome="failed"``. Both are restricted to
``tool_names``, excluding host tools and output-validation retries.
``n_failed_tools`` counts ``RetryPromptPart`` as well as failed returns.
``_cite`` rejects a call with ``ModelRetry`` rather than ``ToolFailed``, and
only the latter sets ``outcome="failed"``, so counting returns alone would
leave a run whose every cite attempt was rejected reporting zero failures.
``requests`` is the run's model-request count, which equals the capability's
own ``request_count`` budget only while the capability is loaded for the
whole run. A deferred capability skips hooks until it loads, so a router
turn before that is counted here and not against its limit.
``n_requests`` counts the run's requests, which matches the capability's own
budget only while it stays loaded a deferred capability skips hooks until
it loads.
"""
search_tool = f"{namespace}_search"
search_calls = 0
@ -84,13 +84,22 @@ def _count_tool_traffic(
)
continue
for part in message.parts:
if not isinstance(part, RetryPromptPart | ToolReturnPart):
continue
if part.tool_name not in tool_names:
continue
if isinstance(part, RetryPromptPart):
failed_tools += 1
elif isinstance(part, ToolReturnPart) and part.outcome == "failed":
elif part.outcome == "failed":
failed_tools += 1
if part.tool_name == search_tool:
rejected_searches += 1
return search_calls, rejected_searches, failed_tools, requests
return ToolTraffic(
n_search_calls=search_calls,
n_rejected_searches=rejected_searches,
n_failed_tools=failed_tools,
n_requests=requests,
)
@dataclass
@ -162,8 +171,10 @@ async def run_capability_question(
executions = getattr(state, "executions", None)
n_executions = len(executions) if executions is not None else 0
n_search_calls, n_rejected_searches, n_failed_tools, n_requests = (
_count_tool_traffic(agent_result.all_messages(), capability.state_namespace)
traffic = _count_tool_traffic(
agent_result.all_messages(),
capability.state_namespace,
capability.tool_names,
)
return CapabilityRunResult(
@ -171,10 +182,14 @@ async def run_capability_question(
cited_uris=cited_uris,
cited_chunk_ids=cited_chunk_ids,
searched_uris=searched_uris,
# Distinct search keys, not searches. Analysis files every in-code
# `search()` under one "_sandbox" key, so twenty sandbox searches read
# as one here; `n_search_calls` is the true count of search *tool*
# calls, and in-code searches are not counted anywhere.
n_searches=len(typed.searches),
n_executions=n_executions,
n_search_calls=n_search_calls,
n_rejected_searches=n_rejected_searches,
n_failed_tools=n_failed_tools,
n_requests=n_requests,
n_search_calls=traffic.n_search_calls,
n_rejected_searches=traffic.n_rejected_searches,
n_failed_tools=traffic.n_failed_tools,
n_requests=traffic.n_requests,
)

View file

@ -18,6 +18,10 @@ from haiku.rag.capabilities.analysis import create_capability as create_analysis
from haiku.rag.capabilities.rag import create_capability as create_rag
from haiku.rag.config.models import AppConfig
ANALYSIS_TOOLS = frozenset(
{"analysis_search", "analysis_execute_code", "analysis_cite"}
)
def test_count_tool_traffic_sees_a_rejected_cite_call():
"""`_cite` rejects with ModelRetry, which is not a failed ToolReturnPart."""
@ -36,12 +40,10 @@ def test_count_tool_traffic_sees_a_rejected_cite_call():
ModelResponse(parts=[TextPart("done")]),
]
_search_calls, rejected_searches, failed_tools, _requests = _count_tool_traffic(
messages, "analysis"
)
traffic = _count_tool_traffic(messages, "analysis", ANALYSIS_TOOLS)
assert failed_tools == 1
assert rejected_searches == 0
assert traffic.n_failed_tools == 1
assert traffic.n_rejected_searches == 0
def test_count_tool_traffic_separates_search_rejections_from_code_errors():
@ -62,14 +64,12 @@ def test_count_tool_traffic_separates_search_rejections_from_code_errors():
ModelResponse(parts=[TextPart("done")]),
]
search_calls, rejected_searches, failed_tools, requests = _count_tool_traffic(
messages, "analysis"
)
traffic = _count_tool_traffic(messages, "analysis", ANALYSIS_TOOLS)
assert search_calls == 0
assert rejected_searches == 0
assert failed_tools == 1
assert requests == 2
assert traffic.n_search_calls == 0
assert traffic.n_rejected_searches == 0
assert traffic.n_failed_tools == 1
assert traffic.n_requests == 2
def test_count_tool_traffic_counts_attempts_not_distinct_queries():
@ -98,13 +98,11 @@ def test_count_tool_traffic_counts_attempts_not_distinct_queries():
ModelResponse(parts=[TextPart("done")]),
]
search_calls, rejected, _failed, requests = _count_tool_traffic(
messages, "analysis"
)
traffic = _count_tool_traffic(messages, "analysis", ANALYSIS_TOOLS)
assert search_calls == 2
assert rejected == 1
assert requests == 2
assert traffic.n_search_calls == 2
assert traffic.n_rejected_searches == 1
assert traffic.n_requests == 2
async def test_runs_rag_capability_without_legacy_capability_layer(tmp_path):

View file

@ -12,6 +12,8 @@ from pydantic_ai.messages import (
InstructionPart,
ModelMessage,
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturn,
ToolReturnPart,
UserPromptPart,
@ -29,7 +31,13 @@ from haiku.rag.store.models.citation import Citation, resolve_citations
from haiku.rag.tools.search import build_binary_parts_from_results
CITATION_GRACE_REQUESTS = 2
"""Requests the cite tool outlives this capability's other tools by."""
"""Requests calling this capability's tools that its cite tool outlives the rest by.
A loop guard, not a budget: cite consumes no retry budget and raises nothing, so
left available forever a stuck model calls it until the agent's own request limit
raises ``UsageLimitExceeded`` and the question returns no answer at all. Only
engagement can loop, which is why other capabilities' turns do not spend it.
"""
CHUNK_ID_MATCH_CUTOFF = 0.75
"""Similarity a cited chunk id needs to be treated as a corrupted known id.
@ -105,6 +113,17 @@ def _compact_old_tool_returns(
return compacted
def _called_own_tool(messages: list[ModelMessage], tool_names: frozenset[str]) -> bool:
"""Whether the model's most recent response called one of these tools."""
for message in reversed(messages):
if isinstance(message, ModelResponse):
return any(
isinstance(part, ToolCallPart) and part.tool_name in tool_names
for part in message.parts
)
return False
@dataclass
class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
db_path: Path
@ -122,6 +141,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
resource_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
search_count: int = field(default=0, repr=False)
request_count: int = field(default=0, repr=False)
grace_requests_used: int = field(default=0, repr=False)
async def for_run(self, ctx: RunContext[Any]) -> "RAGCapabilityBase[StateT]":
outer = getattr(ctx.deps, "state", None)
@ -138,6 +158,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
resource_lock=asyncio.Lock(),
search_count=0,
request_count=0,
grace_requests_used=0,
)
run_capability._sync_state()
return run_capability
@ -167,6 +188,10 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
InstructionPart(content=instruction, dynamic=True),
],
)
if self._request_limit_reached and _called_own_tool(
request_context.messages, self.tool_names
):
self.grace_requests_used += 1
self.request_count += 1
return request_context
@ -186,9 +211,9 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
if self._request_limit_reached:
return (
f"The {self.state_namespace} capability has reached its request "
f"limit. Only {self._cite_tool_name} remains available: register "
"the chunk_ids supporting your answer, then answer from the "
"evidence already gathered."
f"limit. Only {self._cite_tool_name} remains among its tools: "
"register the chunk_ids supporting your answer, then answer from "
"the evidence already gathered."
)
if spent := self._spent_tool_names():
names = ", ".join(sorted(spent))
@ -215,14 +240,10 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
) -> list[ToolDefinition]:
"""Remove this capability's tools past its limit, cite tool last.
The cite tool outlives the others by ``CITATION_GRACE_REQUESTS`` so a run
that exhausts its budget can still record the evidence it gathered.
Tools whose own budget is spent stay declared on purpose. Removing one
makes a model that calls it anyway hit ``Unknown tool name``, which is
charged against the agent's unknown-tool retry budget and kills the run
after two attempts. A spent tool that keeps failing wastes requests; a
withdrawn one loses the whole answer.
makes a model that calls it anyway hit ``Unknown tool name``, charged
against the agent's unknown-tool retry budget, which kills the run after
two attempts. A spent tool that keeps failing only wastes requests.
"""
if self._citation_grace_expired:
return [tool for tool in tool_defs if tool.capability_id != self.id]
@ -235,11 +256,7 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
]
def _evidence_tool_names(self) -> set[str]:
"""Tools that can bring new evidence into the run.
Drives the spent-budget notice: while one of these still has budget the
model must be pointed at it, not told to answer from what it has.
"""
"""Tools that can bring new evidence into the run."""
return {f"{self.state_namespace}_search"}
def _spent_tool_names(self) -> set[str]:
@ -264,10 +281,9 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]):
@property
def _citation_grace_expired(self) -> bool:
return (
self.request_limit is not None
and self.request_count >= self.request_limit + CITATION_GRACE_REQUESTS
)
# No `request_limit is None` guard: the counter only advances under
# `_request_limit_reached`, which already requires a limit.
return self.grace_requests_used >= CITATION_GRACE_REQUESTS
async def after_run(
self, ctx: RunContext[Any], *, result: AgentRunResult[Any]

View file

@ -17,7 +17,11 @@ from pydantic_ai.models.function import FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RunUsage
from haiku.rag.capabilities._base import _compact_old_tool_returns
from haiku.rag.capabilities._base import (
CITATION_GRACE_REQUESTS,
_called_own_tool,
_compact_old_tool_returns,
)
from haiku.rag.capabilities.analysis import AnalysisCapability, AnalysisState
from haiku.rag.capabilities.analysis import create_capability as create_analysis
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGCapability, RAGState
@ -494,6 +498,40 @@ async def test_spent_search_budget_is_announced_but_keeps_the_tool(rag_db):
assert "spent its budget for rag_search" in seen_instructions[1]
def test_grace_window_ignores_other_capabilities_turns():
"""Only this capability's own tool calls may spend its cite window.
A multi-capability agent spends turns elsewhere; those must not expire the
window that exists to give this capability a chance to cite.
"""
rag_tools = frozenset({"rag_search", "rag_cite"})
# Nothing to attribute before the model has responded at all.
assert not _called_own_tool(
[ModelRequest(parts=[UserPromptPart(content="q")])], rag_tools
)
assert not _called_own_tool(
[ModelResponse(parts=[ToolCallPart("analysis_search", {"query": "x"})])],
rag_tools,
)
assert not _called_own_tool(
[ModelResponse(parts=[TextPart("just talking")])], rag_tools
)
assert _called_own_tool(
[ModelResponse(parts=[ToolCallPart("rag_cite", {"chunk_ids": ["a"]})])],
rag_tools,
)
# Only the most recent response counts, not any earlier one.
assert not _called_own_tool(
[
ModelResponse(parts=[ToolCallPart("rag_cite", {"chunk_ids": ["a"]})]),
ModelRequest(parts=[UserPromptPart(content="next")]),
ModelResponse(parts=[ToolCallPart("analysis_search", {"query": "x"})]),
],
rag_tools,
)
@pytest.mark.asyncio
async def test_spent_search_notice_points_at_code_while_it_has_budget(temp_db_path):
"""Analysis must be sent to the sandbox, not told to answer, while it can.
@ -511,13 +549,13 @@ async def test_spent_search_notice_points_at_code_while_it_has_budget(temp_db_pa
assert notice is not None
assert "analysis_search" in notice
assert "analysis_execute_code" in notice
assert "Answer from the evidence already gathered" not in notice
# Once the code budget is gone too there is nowhere left to go.
# Once the code budget is gone too there is nowhere left to send it.
capability.execute_count = config.analysis.max_executions
notice = capability._budget_notice()
assert notice is not None
assert "Answer from the evidence already gathered" in notice
assert "analysis_execute_code" in notice
assert capability._evidence_tool_names() <= capability._spent_tool_names()
@pytest.mark.asyncio
@ -531,7 +569,8 @@ async def test_spent_search_notice_tells_rag_to_answer(temp_db_path):
notice = capability._budget_notice()
assert notice is not None
assert "Answer from the evidence already gathered" in notice
assert "rag_search" in notice
assert capability._evidence_tool_names() == {"rag_search"}
@pytest.mark.asyncio
@ -591,7 +630,7 @@ async def test_exhausted_run_can_still_register_citations(rag_db):
deps = Deps()
async with agent.iter("question", deps=deps) as run:
async for node in run:
async for _node in run:
if chunk_id is None:
searches = deps.state.get("rag", {}).get("searches") or {}
for results in searches.values():
@ -626,7 +665,7 @@ async def test_cite_tool_is_withdrawn_after_the_grace_window(temp_db_path):
notice = capability._budget_notice()
assert notice is not None and "rag_cite" in notice
capability.request_count = 4
capability.grace_requests_used = CITATION_GRACE_REQUESTS
kept = await capability.prepare_tools(ctx, cast(Any, tool_defs))
assert kept == []
# The notice must never point at a tool prepare_tools has withdrawn: