Skip to content

Commit 4b7c369

Browse files
committed
fix(retain): a zero retry budget must still perform the initial fact-extraction request
1 parent eca0fd5 commit 4b7c369

2 files changed

Lines changed: 171 additions & 7 deletions

File tree

hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,10 +1362,15 @@ async def _extract_facts_from_chunk(
13621362
llm_max_retries = (
13631363
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
13641364
)
1365+
# Legacy OUTER content-validation attempt count (re-prompts on malformed JSON),
1366+
# floored at 1 so a zero transport-retry budget still performs the initial request
1367+
# (#2731). The raw budget is still forwarded unchanged to llm_config.call(), which
1368+
# owns transport retries (`range(N + 1)` requests internally) — do NOT add +1 here.
1369+
outer_attempts = max(1, llm_max_retries)
13651370
last_error: Exception | None = None
13661371

13671372
usage = TokenUsage() # Track cumulative usage across retries
1368-
for attempt in range(llm_max_retries):
1373+
for attempt in range(outer_attempts):
13691374
try:
13701375
initial_backoff = (
13711376
config.retain_llm_initial_backoff
@@ -1401,9 +1406,9 @@ async def _extract_facts_from_chunk(
14011406
# Handle malformed LLM responses
14021407
coerced_response_json = _coerce_fact_response(extraction_response_json)
14031408
if coerced_response_json is None:
1404-
if attempt < llm_max_retries - 1:
1409+
if attempt < outer_attempts - 1:
14051410
logger.warning(
1406-
f"LLM returned non-dict JSON on attempt {attempt + 1}/{llm_max_retries}: {type(extraction_response_json).__name__}. Retrying..."
1411+
f"LLM returned non-dict JSON on attempt {attempt + 1}/{outer_attempts}: {type(extraction_response_json).__name__}. Retrying..."
14071412
)
14081413
continue
14091414
else:
@@ -1412,7 +1417,7 @@ async def _extract_facts_from_chunk(
14121417
# worker's retry machinery and ultimately fails loudly — never
14131418
# silently commit the document with 0 facts. See issue #1833.
14141419
raise RuntimeError(
1415-
f"Fact extraction failed: LLM returned non-dict JSON after {llm_max_retries} attempts "
1420+
f"Fact extraction failed: LLM returned non-dict JSON after {outer_attempts} attempts "
14161421
f"({type(extraction_response_json).__name__}). Raw: {str(extraction_response_json)[:500]}"
14171422
)
14181423
extraction_response_json = coerced_response_json
@@ -1631,9 +1636,9 @@ def get_value(field_name):
16311636
continue
16321637

16331638
# If we got malformed facts and haven't exhausted retries, try again
1634-
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < llm_max_retries - 1:
1639+
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < outer_attempts - 1:
16351640
logger.warning(
1636-
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{llm_max_retries}. Retrying..."
1641+
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{outer_attempts}. Retrying..."
16371642
)
16381643
continue
16391644

@@ -1672,7 +1677,7 @@ def get_value(field_name):
16721677
# If we exhausted all retries, raise the last error or a descriptive fallback
16731678
if last_error is not None:
16741679
raise last_error
1675-
raise RuntimeError(f"Fact extraction failed after {llm_max_retries} attempts: LLM did not return valid JSON")
1680+
raise RuntimeError(f"Fact extraction failed after {outer_attempts} attempts: LLM did not return valid JSON")
16761681

16771682

16781683
async def _extract_facts_with_auto_split(

hindsight-api-slim/tests/test_fact_extraction_retry.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,40 @@ async def test_retain_llm_max_retries_overrides_global():
263263
assert llm_config.call.call_count == 5
264264

265265

266+
@pytest.mark.asyncio
267+
async def test_zero_retry_budget_performs_single_chunk_extraction_call():
268+
"""
269+
Direct _extract_facts_from_chunk with a retry budget of 0 (issue #2731):
270+
the outer loop must still run once, and the RAW budget (0) must reach the
271+
provider so it stays the single owner of transport retries.
272+
"""
273+
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
274+
275+
config = _make_config(llm_max_retries=3, retain_llm_max_retries=0)
276+
llm_config = _make_llm_config(
277+
mock_response={"facts": [{"what": "Alice visited Paris", "when": "2023", "who": "Alice", "why": "vacation"}]}
278+
)
279+
280+
with patch(
281+
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
282+
return_value=("system prompt", MagicMock()),
283+
):
284+
facts, _usage = await _extract_facts_from_chunk(
285+
chunk="Alice visited Paris in 2023.",
286+
chunk_index=0,
287+
total_chunks=1,
288+
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
289+
context="",
290+
llm_config=llm_config,
291+
config=config,
292+
agent_name="test-agent",
293+
)
294+
295+
assert llm_config.call.call_count == 1
296+
assert llm_config.call.call_args.kwargs["max_retries"] == 0
297+
assert len(facts) == 1
298+
299+
266300
@pytest.mark.asyncio
267301
async def test_none_event_date_with_empty_facts_no_crash():
268302
"""
@@ -376,3 +410,128 @@ def test_build_request_body_omits_temperature_when_none():
376410

377411
body = _build_request_body(_make_batch_llm_config(), _make_batch_temp_config(None), "sys", "user", dict)
378412
assert "temperature" not in body
413+
414+
415+
# --- Retry budget semantics (issue #2731) -----------------------------------
416+
#
417+
# A retry BUDGET of N means N retries *after* the initial request — the meaning
418+
# every provider already implements (`for attempt in range(max_retries + 1)`) and
419+
# the meaning the OpenAI SDK documents for `max_retries=0` ("disable retries",
420+
# i.e. one request). The tests below drive the *public* extract_facts_from_text
421+
# entry point with the real HindsightConfig an operator gets from
422+
# HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES, so they exercise the whole
423+
# env -> config -> chunk -> auto-split -> extraction -> provider chain rather
424+
# than a private helper.
425+
426+
_VALID_EXTRACTION_RESPONSE = {
427+
"facts": [{"what": "Alice visited Paris", "when": "2023", "who": "Alice", "why": "vacation"}]
428+
}
429+
430+
431+
def _make_recording_llm(mock_response):
432+
"""LLMProvider double returning ``mock_response`` and recording call kwargs."""
433+
from hindsight_api.engine.llm_wrapper import LLMProvider
434+
from hindsight_api.engine.response_models import TokenUsage
435+
436+
llm = MagicMock(spec=LLMProvider)
437+
llm.provider = "mock"
438+
llm.call = AsyncMock(return_value=(mock_response, TokenUsage()))
439+
return llm
440+
441+
442+
@pytest.fixture
443+
def retain_config(monkeypatch):
444+
"""Factory for the real config an operator gets from the retry-budget env var.
445+
446+
Mirrors the reporter's setup: HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES is the only
447+
knob they touch. The global budget is pinned so the "unset" row provably
448+
exercises the fallback.
449+
"""
450+
from hindsight_api.config import _get_raw_config, clear_config_cache
451+
452+
def _build(retain_budget: str | None):
453+
monkeypatch.setenv("HINDSIGHT_API_LLM_MAX_RETRIES", "3")
454+
if retain_budget is None:
455+
monkeypatch.delenv("HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES", raising=False)
456+
else:
457+
monkeypatch.setenv("HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES", retain_budget)
458+
clear_config_cache()
459+
return _get_raw_config()
460+
461+
yield _build
462+
clear_config_cache()
463+
464+
465+
@pytest.mark.asyncio
466+
@pytest.mark.parametrize(
467+
"retain_budget, expected_forwarded_retries",
468+
[
469+
# The reported repro: a gateway owns transport retries, so the operator
470+
# sets the budget to 0. This used to perform ZERO extraction requests and
471+
# raise "Fact extraction failed after 0 attempts".
472+
pytest.param("0", 0, id="zero_budget_gateway_owns_retries"),
473+
pytest.param("1", 1, id="budget_one"),
474+
pytest.param("3", 3, id="budget_three"),
475+
pytest.param(None, 3, id="unset_falls_back_to_global"),
476+
],
477+
)
478+
async def test_retry_budget_always_performs_initial_extraction_request(
479+
retain_config, retain_budget, expected_forwarded_retries
480+
):
481+
"""Any retry budget — including 0 — must still perform the initial request."""
482+
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
483+
484+
config = retain_config(retain_budget)
485+
llm = _make_recording_llm(_VALID_EXTRACTION_RESPONSE)
486+
487+
facts, _chunks, _usage = await extract_facts_from_text(
488+
text="Alice visited Paris in 2023.",
489+
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
490+
llm_config=llm,
491+
agent_name="test-agent",
492+
config=config,
493+
context="",
494+
)
495+
496+
assert llm.call.call_count == 1
497+
assert len(facts) == 1
498+
assert "Alice visited Paris" in facts[0].fact
499+
# The RAW budget reaches the provider — not the outer attempt count — so the
500+
# provider stays the single retry owner (0 => gateway owns transport retries).
501+
assert llm.call.call_args.kwargs["max_retries"] == expected_forwarded_retries
502+
503+
504+
@pytest.mark.asyncio
505+
@pytest.mark.parametrize(
506+
"retain_budget, expected_calls",
507+
[
508+
# Budget 0 means "one request, no retry" — one attempt, then a real
509+
# content error. Never zero attempts.
510+
pytest.param("0", 1, id="zero_budget_one_attempt"),
511+
pytest.param("1", 1, id="budget_one"),
512+
pytest.param("3", 3, id="budget_three"),
513+
],
514+
)
515+
async def test_malformed_response_still_attempts_then_fails_loudly(retain_config, retain_budget, expected_calls):
516+
"""A malformed response must fail on content, never on a skipped request.
517+
518+
Guards the #1833 contract (raise, never silently return []) while proving the
519+
zero budget spends its one attempt before failing.
520+
"""
521+
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
522+
523+
config = retain_config(retain_budget)
524+
llm = _make_recording_llm(["not a dict"])
525+
526+
with pytest.raises(RuntimeError, match="non-dict JSON") as exc:
527+
await extract_facts_from_text(
528+
text="Alice visited Paris in 2023.",
529+
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
530+
llm_config=llm,
531+
agent_name="test-agent",
532+
config=config,
533+
context="",
534+
)
535+
536+
assert llm.call.call_count == expected_calls
537+
assert "after 0 attempts" not in str(exc.value)

0 commit comments

Comments
 (0)