fix(retain): guard saturated fact extraction output - #3654
Closed
kamilfurtak wants to merge 3 commits into
Closed
Conversation
kamilfurtak
force-pushed
the
agent/retain-facts-saturation-guard
branch
from
August 19, 2026 20:50
9294e9b to
e6e7676
Compare
The fixed 16-fact bound was tuned against a small local model with a 4096-token
retain ceiling. Deployments with a larger ceiling would be pushed into constant
splitting, since 16 facts is a small fraction of what their budget allows.
Derive the bound from `retain_max_completion_tokens` instead, using an approximate
per-fact output cost and halving the budget so the item bound bites before the
token limit rather than at the same point. A conservative floor still applies when
no ceiling is configured.
budget 4096 -> 24 facts
budget 65536 -> 385 facts
unset -> 16 facts (floor)
kamilfurtak
force-pushed
the
agent/retain-facts-saturation-guard
branch
from
August 19, 2026 20:52
e6e7676 to
971bd64
Compare
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fact extraction may enter a degenerate or excessively dense structured-output response where the
factsarray keeps growing until the output token limit. Introduce a saturation boundary that never silently accepts a capped response: reaching the boundary raisesOutputTooLongErrorand delegates completeness to the existing recursive split/retry path.The boundary is a split threshold, not an extraction or storage limit. A legitimate chunk with more than 16 facts is split and all sub-chunk facts are still returned.
Reproduction / impact
In Hindsight 0.9.1, the same small retain chunk can sometimes complete normally and sometimes produce a degenerate/looping response that keeps emitting facts until the output ceiling. Without a deterministic saturation signal, the extraction path has no reliable point at which to split the input.
This PR adds:
maxItems=16to the retainfactsarray when the configured backend supports that JSON Schema keyword;len(facts) >= 16guard that raisesOutputTooLongErrorinstead of treating the boundary response as complete;Backend capability behavior
For
llm_supports_max_items=True, the schema-level boundary prevents capable structured-output backends from accepting more than the split threshold. Forllm_supports_max_items=False, the wire schema remains uncapped because some providers rejectmaxItems; the runtime guard is authoritative when a complete response contains 16 or more facts. It cannot interrupt generation in that case, so a provider that loops untilfinish_reason=lengthstill relies on the existing output-too-long handling to trigger the split.Scope
16is a saturation/circuit-breaker threshold, not a legal maximum fact count or a truncation policy.[[{turn}, ...]]splitter fix in fix(retain): preserve nested conversation chunks during output retry #3652.Validation
tests/test_fact_extraction_retry.py tests/test_fact_extraction_entities_schema.py: 40 passedOutputTooLongError.The repository hook was not used because this checkout does not have
uvinstalled; the equivalent targeted Ruff checks were run directly.Depends on #3652
This PR raises how often
OutputTooLongErroris signalled, which routes more workinto the existing split path. #3652 is what makes that path succeed for
conversation payloads that arrive wrapped in an extra array. Merging this without
#3652 would increase the number of splits while leaving those splits unable to
make progress.
On the bound
The bound is derived from
retain_max_completion_tokensrather than fixed, so adeployment with a large ceiling is not pushed into constant splitting:
An earlier revision hard-coded 16, which was tuned against a 4096-token ceiling on
a small local model. At that ceiling a fact object costs roughly 85 output tokens,
so a bound of 48 would have landed at the same point as the token limit and done
nothing. Halving the budget keeps the item bound biting first.
Field data behind the floor, from 168 single-chunk extractions on one deployment:
median 2 facts, p95 10, max 29.