@@ -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
267301async 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