Skip to content

Commit e1a1783

Browse files
author
Sebastian Braun
committed
fix(cli): retry failed concept/entity generations before giving up, not the whole document
_compile_concepts now runs a deferred, end-of-first-pass sweep for concepts/ entities that failed their first attempt, under "normal" and "fail-at-end" insert_mode (not "fail-fast", which already aborts before the rest of the batch runs). This gives transient failures a real second chance, with the prompt cache still warm, before insert_mode's strict modes decide the document is incomplete. _run_compile_with_retry (cli.py) no longer retries the whole document when ConceptCompilationError is raised: insert_mode already exhausted the per-call and per-item retries, so a full recompile would just repeat the same failures. Other exceptions keep their existing 2-attempt retry.
1 parent 16b9eb0 commit e1a1783

4 files changed

Lines changed: 177 additions & 0 deletions

File tree

‎openkb/agent/compiler.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,27 @@ def _format_known_targets(targets: set[str]) -> str:
16031603
return "\n".join(f"- {t}" for t in sorted(targets))
16041604

16051605

1606+
async def _sweep_failed_generations(results: list, factories: list, kind: str) -> list:
1607+
"""Retry, once, only the items that failed the first pass.
1608+
1609+
Not used under ``insert_mode="fail-fast"`` — that mode already aborts on
1610+
the first failure without waiting for the rest of the batch.
1611+
"""
1612+
failed_idx = [i for i, r in enumerate(results) if isinstance(r, Exception)]
1613+
if not failed_idx:
1614+
return results
1615+
logger.warning(
1616+
"Retrying %d failed %s generation(s) after the first pass...", len(failed_idx), kind
1617+
)
1618+
retry_results = await asyncio.gather(
1619+
*(factories[i]() for i in failed_idx), return_exceptions=True
1620+
)
1621+
results = list(results)
1622+
for idx, r in zip(failed_idx, retry_results):
1623+
results[idx] = r
1624+
return results
1625+
1626+
16061627
async def _compile_concepts(
16071628
wiki_dir: Path,
16081629
kb_dir: Path,
@@ -2025,6 +2046,12 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]:
20252046
tasks.extend(asyncio.create_task(_gen_create(c)) for c in create_items)
20262047
tasks.extend(asyncio.create_task(_gen_update(c)) for c in update_items)
20272048

2049+
# Zero-arg factories, same order as `tasks`, so a failed item can be
2050+
# re-run in isolation by the end-of-first-pass sweep below.
2051+
concept_factories = [(lambda c=c: _gen_create(c)) for c in create_items] + [
2052+
(lambda c=c: _gen_update(c)) for c in update_items
2053+
]
2054+
20282055
# --- Step 3 (entities): build the entity task list up front so it can be
20292056
# gathered concurrently with the concept tasks below. Entity coroutines
20302057
# return 4-arity tuples (name, content, brief, type), so their results are
@@ -2035,6 +2062,10 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]:
20352062
entity_tasks.extend(asyncio.create_task(_gen_entity_create(e)) for e in entity_create)
20362063
entity_tasks.extend(asyncio.create_task(_gen_entity_update(e)) for e in entity_update)
20372064

2065+
entity_factories = [(lambda e=e: _gen_entity_create(e)) for e in entity_create] + [
2066+
(lambda e=e: _gen_entity_update(e)) for e in entity_update
2067+
]
2068+
20382069
concept_names: list[str] = []
20392070
concept_briefs_map: dict[str, str] = {}
20402071
pending_writes: list[tuple[str, str, bool, str]] = []
@@ -2086,6 +2117,14 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]:
20862117
asyncio.gather(*tasks, return_exceptions=True),
20872118
asyncio.gather(*entity_tasks, return_exceptions=True),
20882119
)
2120+
# One more chance for exactly the items that failed, now that the
2121+
# rest of the batch has run (transient conditions get real
2122+
# wall-clock time to clear, prompt cache is still warm). Not used
2123+
# under "fail-fast" — that mode already aborted above.
2124+
results = await _sweep_failed_generations(results, concept_factories, "concept")
2125+
entity_results = await _sweep_failed_generations(
2126+
entity_results, entity_factories, "entity"
2127+
)
20892128

20902129
failure_types: list[str] = []
20912130
if tasks:

‎openkb/cli.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,11 +429,17 @@ def _snapshot_add_paths(
429429

430430

431431
def _run_compile_with_retry(coro_factory, label: str) -> None:
432+
from openkb.agent.compiler import ConceptCompilationError
433+
432434
click.echo(f" {label}...")
433435
for attempt in range(2):
434436
try:
435437
asyncio.run(coro_factory())
436438
return
439+
except ConceptCompilationError:
440+
# insert_mode already decided this document is incomplete —
441+
# a full recompile would just repeat the same failures.
442+
raise
437443
except Exception as exc:
438444
if attempt == 0:
439445
click.echo(" Retrying compilation in 2s...")

‎tests/test_cli.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,60 @@ def test_init_model_prompt_accepts_input(tmp_path):
303303
assert config["model"] == "anthropic/claude-opus-4-6"
304304

305305

306+
class TestRunCompileWithRetry:
307+
"""insert_mode already decided a document is incomplete when it raises
308+
ConceptCompilationError, so _run_compile_with_retry must not spend a
309+
second full-document recompile repeating the same failures — but every
310+
other exception keeps its original 2-attempt retry."""
311+
312+
def test_concept_compilation_error_is_not_retried(self):
313+
from openkb.agent.compiler import ConceptCompilationError
314+
from openkb.cli import _run_compile_with_retry
315+
316+
calls = 0
317+
318+
async def _factory():
319+
nonlocal calls
320+
calls += 1
321+
raise ConceptCompilationError("insert_mode said no")
322+
323+
with (
324+
patch("openkb.cli.time.sleep"),
325+
pytest.raises(ConceptCompilationError),
326+
):
327+
_run_compile_with_retry(_factory, "compiling")
328+
assert calls == 1
329+
330+
def test_other_exceptions_still_get_one_retry(self):
331+
from openkb.cli import _run_compile_with_retry
332+
333+
calls = 0
334+
335+
async def _factory():
336+
nonlocal calls
337+
calls += 1
338+
if calls == 1:
339+
raise RuntimeError("transient")
340+
341+
with patch("openkb.cli.time.sleep"):
342+
_run_compile_with_retry(_factory, "compiling")
343+
assert calls == 2
344+
345+
def test_other_exceptions_raise_after_exhausting_retries(self):
346+
from openkb.cli import _run_compile_with_retry
347+
348+
calls = 0
349+
350+
async def _factory():
351+
nonlocal calls
352+
calls += 1
353+
raise RuntimeError("still broken")
354+
355+
with patch("openkb.cli.time.sleep"), pytest.raises(RuntimeError, match="still broken"):
356+
_run_compile_with_retry(_factory, "compiling")
357+
assert calls == 2
358+
359+
306360
class TestQueryStreamGate:
307361
"""Regression tests for issue #34.
308362

‎tests/test_compiler.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,6 +2138,31 @@ def _plan_response(self):
21382138
}
21392139
)
21402140

2141+
@staticmethod
2142+
def _flaky_once_acompletion():
2143+
"""Fails the first time "concept-b" is generated, succeeds on any
2144+
later call — simulates a transient error that clears up by the time
2145+
the end-of-first-pass sweep retries it."""
2146+
attempts: dict[str, int] = {}
2147+
2148+
def _call(*args, **kwargs):
2149+
messages = kwargs.get("messages") or (args[1] if len(args) > 1 else [])
2150+
last_content = messages[-1]["content"] if messages else ""
2151+
if "concept-b" in last_content:
2152+
attempts["concept-b"] = attempts.get("concept-b", 0) + 1
2153+
if attempts["concept-b"] == 1:
2154+
raise RuntimeError("boom: transient concept-b failure")
2155+
mock_resp = MagicMock()
2156+
mock_resp.choices = [MagicMock()]
2157+
mock_resp.choices[0].message.content = json.dumps(
2158+
{"brief": "b", "content": "# Concept\n\nBody."}
2159+
)
2160+
mock_resp.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
2161+
mock_resp.usage.prompt_tokens_details = None
2162+
return mock_resp
2163+
2164+
return _call
2165+
21412166
@pytest.mark.asyncio
21422167
async def test_normal_mode_keeps_partial_success_silent(self, tmp_path):
21432168
"""Default/omitted insert_mode: unchanged behavior — no exception,
@@ -2244,6 +2269,59 @@ async def test_fail_at_end_no_failures_does_not_raise(self, tmp_path):
22442269
)
22452270
assert (wiki / "concepts" / "concept-a.md").exists()
22462271

2272+
@pytest.mark.asyncio
2273+
async def test_normal_mode_sweep_recovers_transient_failure(self, tmp_path):
2274+
"""A concept that fails its first attempt but succeeds on retry gets
2275+
picked up by the end-of-first-pass sweep (a tier above the low-level
2276+
_llm_call retry) and ends up written, with no exception raised."""
2277+
wiki = self._setup_wiki(tmp_path)
2278+
with patch("openkb.agent.compiler.litellm") as mock_litellm:
2279+
mock_litellm.completion = MagicMock(
2280+
side_effect=_mock_completion([self._plan_response()])
2281+
)
2282+
mock_litellm.acompletion = AsyncMock(side_effect=self._flaky_once_acompletion())
2283+
await _compile_concepts(
2284+
wiki,
2285+
tmp_path,
2286+
"gpt-4o-mini",
2287+
{"role": "system", "content": "s"},
2288+
{"role": "user", "content": "d"},
2289+
"summary",
2290+
"test-doc",
2291+
5,
2292+
insert_mode="normal",
2293+
)
2294+
assert (wiki / "concepts" / "concept-a.md").exists()
2295+
assert (wiki / "concepts" / "concept-b.md").exists()
2296+
2297+
@pytest.mark.asyncio
2298+
async def test_fail_fast_does_not_get_a_sweep_retry(self, tmp_path):
2299+
"""insert_mode="fail-fast" aborts on the first failure without
2300+
waiting for the rest of the batch, so it never reaches the sweep —
2301+
even a failure that would have cleared up on retry still aborts."""
2302+
from openkb.agent.compiler import ConceptCompilationError
2303+
2304+
wiki = self._setup_wiki(tmp_path)
2305+
with patch("openkb.agent.compiler.litellm") as mock_litellm:
2306+
mock_litellm.completion = MagicMock(
2307+
side_effect=_mock_completion([self._plan_response()])
2308+
)
2309+
mock_litellm.acompletion = AsyncMock(side_effect=self._flaky_once_acompletion())
2310+
with pytest.raises(ConceptCompilationError):
2311+
await _compile_concepts(
2312+
wiki,
2313+
tmp_path,
2314+
"gpt-4o-mini",
2315+
{"role": "system", "content": "s"},
2316+
{"role": "user", "content": "d"},
2317+
"summary",
2318+
"test-doc",
2319+
5,
2320+
insert_mode="fail-fast",
2321+
)
2322+
assert not (wiki / "concepts" / "concept-a.md").exists()
2323+
assert not (wiki / "concepts" / "concept-b.md").exists()
2324+
22472325
@pytest.mark.asyncio
22482326
async def test_compile_short_doc_reads_insert_mode_from_kb_config(self, tmp_path):
22492327
"""End-to-end wiring check: compile_short_doc reads insert_mode from

0 commit comments

Comments
 (0)