diff --git a/config.yaml.example b/config.yaml.example index 47d2fda2b..324bd0ebe 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -8,6 +8,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex # large PDFs. Omit to let each stage apply its own default. # concurrency: 5 +# Optional: how a partially-failed compile (some concept/entity couldn't be +# generated, e.g. a transient LLM error) is reported for a single `add`. +# normal (default) log a warning, still report the file "added". +# fail-fast abort as soon as the first concept/entity fails; the add is +# rolled back and reported "failed". +# fail-at-end attempt every planned concept/entity first (so every failure +# for the file is logged in one pass), then roll back and +# report "failed" if anything failed. +# insert_mode: normal + # Optional: whether the LLM agents (query, chat, lint, skill) may call tools # in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent # defaults. Setting it applies the SAME value to every agent: diff --git a/examples/configuration/README.md b/examples/configuration/README.md index 25e5b3a85..6009dbfb7 100644 --- a/examples/configuration/README.md +++ b/examples/configuration/README.md @@ -76,6 +76,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex # large PDFs. Omit to let each stage apply its own default. # concurrency: 5 +# Optional: how a partially-failed compile (some concept/entity couldn't be +# generated, e.g. a transient LLM error) is reported for a single `add`. +# normal (default) log a warning, still report the file "added". +# fail-fast abort as soon as the first concept/entity fails; the add is +# rolled back and reported "failed". +# fail-at-end attempt every planned concept/entity first (so every failure +# for the file is logged in one pass), then roll back and +# report "failed" if anything failed. +# insert_mode: normal + # Optional: whether the LLM agents (query, chat, lint, skill) may call tools # in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent # defaults. Setting it applies the SAME value to every agent: @@ -112,6 +122,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex | `language` | `en` | Language the wiki is written in. | | `pageindex_threshold` | `20` | PDFs with this many pages **or more** take the long-doc (PageIndex) path; shorter ones go through the short-doc path. See [`pageindex-cloud/`](../pageindex-cloud/). | | `concurrency` | `null` | Caps concurrent LLM calls OpenKB makes during ingest — both PageIndex's indexing of a long document and OpenKB's own concept/entity compilation. The two never run at once for the same document, so one setting covers both. Lower it if you hit provider rate limits or "too many open files" on large PDFs. `null` lets each stage apply its own default. | +| `insert_mode` | `normal` | How a partially-failed compile (some concept/entity couldn't be generated) is reported. `normal` logs a warning and still reports the file "added". `fail-fast` aborts on the first failure; `fail-at-end` attempts every planned concept/entity first (so every failure is logged in one pass). Both strict modes roll back the add and report it "failed" instead of "added". | | `parallel_tool_calls` | unset | Whether the LLM agents (query, chat, lint, skill) may call tools in parallel. Unset keeps OpenKB's per-agent defaults; `true`/`false` force allow/sequential for every agent; `null` omits the setting (provider default). **Amazon Bedrock needs `null`** (see below). | | `entity_types` | 7 defaults | Custom vocabulary for entity pages. `other` is always kept. | | `litellm:` | – | A pass-through block for LiteLLM. See below. | diff --git a/openkb/agent/compiler.py b/openkb/agent/compiler.py index d0c9f878d..84ae52727 100644 --- a/openkb/agent/compiler.py +++ b/openkb/agent/compiler.py @@ -397,6 +397,19 @@ class TruncatedResponseError(Exception): treat truncation as a failure (so a partial page is skipped, not written).""" +class ConceptCompilationError(Exception): + """Raised by ``_compile_concepts`` when ``insert_mode`` is ``"fail-fast"`` + or ``"fail-at-end"`` and one or more planned concept/entity updates could + not be generated for a document (see ``openkb.config.resolve_insert_mode``). + + Propagates through ``compile_short_doc``/``compile_long_doc`` up to + ``cli._add_single_file_locked``'s ``commit_body``, where the existing + mutation-snapshot rollback (``openkb.add_coordinator``) already reverts + every wiki/raw change for the add and reports the file as ``"failed"`` — + no separate rollback path is needed for strict mode. + """ + + def _llm_call( model: str, messages: list[dict], @@ -1604,6 +1617,7 @@ async def _compile_concepts( rewrite_summary: bool = False, entity_types: list[str] | None = None, bundle=None, + insert_mode: str = "normal", ) -> None: """Shared Steps 2-4: concepts plan → generate/update → index. @@ -1613,6 +1627,27 @@ async def _compile_concepts( written to disk. When ``rewrite_summary=True`` (short-doc path), the summary is rewritten by the LLM after concepts are finalized so its wikilinks reflect the actual concept pages on disk. + + ``insert_mode`` (see ``openkb.config.resolve_insert_mode``) controls what + happens when one or more planned concept/entity updates cannot be + generated: + + - ``"normal"`` (default): unchanged behavior — failures are logged as + warnings and whatever *did* generate is written; the document as a + whole is still considered compiled. + - ``"fail-fast"``: the first concept/entity generation failure cancels + every other still-pending (not yet started) generation in this batch + and immediately raises ``ConceptCompilationError`` — nothing from this + batch is written. + - ``"fail-at-end"``: every planned concept/entity generation is attempted + (so every failure for this document is logged in one pass) and + whatever succeeded is written, same as "normal" — but + ``ConceptCompilationError`` is raised at the end if anything failed. + + In both strict modes the raised exception is expected to propagate out of + ``compile_short_doc``/``compile_long_doc`` so the caller's existing + mutation rollback discards this add entirely (see + ``ConceptCompilationError``). """ source_file = f"summaries/{doc_name}.md" @@ -1627,6 +1662,20 @@ async def _compile_concepts( concept_briefs = _read_concept_briefs(wiki_dir) entity_briefs = _read_entity_briefs(wiki_dir) + def _maybe_raise_incomplete(reason: str) -> None: + """Raise ``ConceptCompilationError`` under a strict ``insert_mode``. + + A no-op under ``"normal"``, matching today's silent-partial-success + behavior. Called from every early-return branch below plus the final + completeness check, so both strict modes cover the "plan came back + unparseable/empty" cases, not just individual concept/entity + generation failures. + """ + if insert_mode in ("fail-fast", "fail-at-end"): + raise ConceptCompilationError( + f"insert_mode={insert_mode!r}: {doc_name!r} compiled incompletely — {reason}" + ) + # Second cache breakpoint: end of the assistant summary message. Covers # (system + doc + summary) for the plan call and every concept call. summary_msg = {"role": "assistant", "content": _cached_text(summary)} @@ -1686,6 +1735,7 @@ def _write_v1_summary_stripped() -> None: f"no concept pages generated. See log (stderr) for details.\n" ) sys.stdout.flush() + _maybe_raise_incomplete("concepts plan response was unparseable") if rewrite_summary: _write_v1_summary_stripped() _update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type) @@ -1706,6 +1756,7 @@ def _write_v1_summary_stripped() -> None: type(parsed).__name__, doc_name, ) + _maybe_raise_incomplete("concepts plan parsed to a scalar, not a usable plan") if rewrite_summary: _write_v1_summary_stripped() _update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type) @@ -1788,6 +1839,13 @@ def _raw_group_count(group: object) -> int: and not entity_update and not entity_related ): + # A genuinely empty plan (original_total == 0) is a complete, valid + # outcome for strict modes too — nothing was planned, so nothing is + # missing. But if items were planned and all got dropped as malformed + # (original_total > 0, already warned above), that's real content + # loss under a strict insert_mode. + if original_total > 0: + _maybe_raise_incomplete("all planned concept/entity items were dropped as malformed") if rewrite_summary: _write_v1_summary_stripped() _update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type) @@ -1964,16 +2022,18 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: return name, content, brief, etype_out tasks = [] - tasks.extend(_gen_create(c) for c in create_items) - tasks.extend(_gen_update(c) for c in update_items) + tasks.extend(asyncio.create_task(_gen_create(c)) for c in create_items) + tasks.extend(asyncio.create_task(_gen_update(c)) for c in update_items) # --- Step 3 (entities): build the entity task list up front so it can be # gathered concurrently with the concept tasks below. Entity coroutines # return 4-arity tuples (name, content, brief, type), so their results are # processed in their own loop rather than mixed with the concept tuples. + # Wrapped in asyncio.create_task (not left as bare coroutines) so a + # "fail-fast" insert_mode can cancel the ones still pending below. entity_tasks = [] - entity_tasks.extend(_gen_entity_create(e) for e in entity_create) - entity_tasks.extend(_gen_entity_update(e) for e in entity_update) + entity_tasks.extend(asyncio.create_task(_gen_entity_create(e)) for e in entity_create) + entity_tasks.extend(asyncio.create_task(_gen_entity_update(e)) for e in entity_update) concept_names: list[str] = [] concept_briefs_map: dict[str, str] = {} @@ -1998,13 +2058,37 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: results, entity_results = ([], []) if tasks or entity_tasks: - results, entity_results = await asyncio.gather( - asyncio.gather(*tasks, return_exceptions=True), - asyncio.gather(*entity_tasks, return_exceptions=True), - ) + if insert_mode == "fail-fast": + # Wait only until the first exception surfaces (or everything + # finishes cleanly) instead of always waiting for the full batch — + # cancelling whatever hasn't started/finished yet saves the LLM + # calls that batch would have made. asyncio.wait requires Tasks + # (not bare coroutines), hence the create_task() wrapping above. + all_tasks = tasks + entity_tasks + done, pending = await asyncio.wait(all_tasks, return_when=asyncio.FIRST_EXCEPTION) + first_exc = next((t.exception() for t in done if t.exception() is not None), None) + if first_exc is not None: + for t in pending: + t.cancel() + if pending: + # Swallow the resulting CancelledErrors; we only need the + # cancellations to settle before raising below. + await asyncio.gather(*pending, return_exceptions=True) + logger.warning("Concept/entity generation failed: %s", first_exc) + raise ConceptCompilationError( + f"insert_mode='fail-fast': aborting compile for {doc_name!r} after a " + f"concept/entity generation failure: {first_exc}" + ) from first_exc + results = [t.result() for t in tasks] + entity_results = [t.result() for t in entity_tasks] + else: + results, entity_results = await asyncio.gather( + asyncio.gather(*tasks, return_exceptions=True), + asyncio.gather(*entity_tasks, return_exceptions=True), + ) + failure_types: list[str] = [] if tasks: - failure_types: list[str] = [] for r in results: if isinstance(r, Exception): logger.warning("Concept generation failed: %s", r) @@ -2028,8 +2112,8 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: ) sys.stdout.flush() + entity_failure_types: list[str] = [] if entity_tasks: - entity_failure_types: list[str] = [] for r in entity_results: if isinstance(r, Exception): logger.warning("Entity generation failed: %s", r) @@ -2052,6 +2136,7 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: sys.stdout.flush() # Strip ghost wikilinks from entity bodies and write each page. + for name, page_content, brief, etype in entity_pending: cleaned, ghosts = strip_ghost_wikilinks(page_content, known_targets) if ghosts: @@ -2201,6 +2286,18 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: entity_meta=entity_meta, ) + # "fail-fast" always raises earlier (see the gather branch above) before + # reaching this point, so only "fail-at-end" needs a completeness check + # here — everything planned was attempted (so every failure for this + # document is already logged above), and only now do we decide whether + # the document as a whole should count as failed. + if insert_mode == "fail-at-end" and (failure_types or entity_failure_types): + raise ConceptCompilationError( + f"insert_mode='fail-at-end': {doc_name!r} had {len(failure_types)} failed " + f"concept(s) and {len(entity_failure_types)} failed entity(ies) — " + f"{', '.join(sorted(set(failure_types + entity_failure_types))) or 'see log (stderr)'}" + ) + async def compile_short_doc( doc_name: str, @@ -2215,11 +2312,12 @@ async def compile_short_doc( Step 1: Build base context A (schema + doc content), generate summary. Steps 2-4: Delegated to ``_compile_concepts``. """ - from openkb.config import resolve_effective_config + from openkb.config import resolve_effective_config, resolve_insert_mode config = resolve_effective_config(kb_dir)[0] language: str = config.get("language", "en") entity_types = resolve_entity_types(config) + insert_mode = resolve_insert_mode(config) wiki_dir = kb_dir / "wiki" schema_md = get_agents_md(wiki_dir) @@ -2281,6 +2379,7 @@ async def compile_short_doc( rewrite_summary=True, entity_types=entity_types, bundle=bundle, + insert_mode=insert_mode, ) finally: # Close per-loop litellm async clients before asyncio.run tears this @@ -2303,11 +2402,12 @@ async def compile_long_doc( The summary page is already written by the indexer. This function generates concept pages and updates the index. """ - from openkb.config import resolve_effective_config + from openkb.config import resolve_effective_config, resolve_insert_mode config = resolve_effective_config(kb_dir)[0] language: str = config.get("language", "en") entity_types = resolve_entity_types(config) + insert_mode = resolve_insert_mode(config) wiki_dir = kb_dir / "wiki" schema_md = get_agents_md(wiki_dir) @@ -2365,6 +2465,7 @@ async def compile_long_doc( doc_type="pageindex", entity_types=entity_types, bundle=bundle, + insert_mode=insert_mode, ) finally: # Close per-loop litellm async clients before asyncio.run tears this diff --git a/openkb/config.py b/openkb/config.py index 95ca8691f..30533fc1f 100644 --- a/openkb/config.py +++ b/openkb/config.py @@ -36,8 +36,15 @@ # global/KB list overrides it wholesale; resolve_entity_types cleans the # effective value on read. "entity_types": list(DEFAULT_ENTITY_TYPES), + # How a partial compile (concept/entity generation failure) is reported. + # "normal" (today's behavior): warned, file still "added". Strict modes + # raise so the mutation rolls back and the file is "failed" — see + # resolve_insert_mode(). + "insert_mode": "normal", } +VALID_INSERT_MODES: tuple[str, ...] = ("normal", "fail-fast", "fail-at-end") + GLOBAL_CONFIG_DIR = Path.home() / ".config" / "openkb" GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / "global.yaml" GLOBAL_CONFIG_LOCK_PATH = GLOBAL_CONFIG_DIR / "global.lock" @@ -262,6 +269,25 @@ def resolve_concurrency(config: dict) -> int | None: return value +def resolve_insert_mode(config: dict) -> str: + """Resolve ``insert_mode:`` — one of ``"normal"`` (default, unchanged), + ``"fail-fast"`` (abort on the first concept/entity failure), or + ``"fail-at-end"`` (run to completion, then fail if anything failed). + Strict modes raise ``ConceptCompilationError`` (``openkb.agent.compiler``), + which the mutation rollback turns into a ``"failed"`` outcome. An invalid + value degrades to ``"normal"`` with a warning. + """ + value = config.get("insert_mode", "normal") + if value not in VALID_INSERT_MODES: + logger.warning( + "config: 'insert_mode' must be one of %s, got %r — using 'normal'.", + VALID_INSERT_MODES, + value, + ) + return "normal" + return value + + def resolve_litellm_settings(config: dict) -> dict[str, Any]: """Resolve the optional ``litellm:`` mapping of LiteLLM module settings. diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 95a57cc4c..2404e0d67 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -2085,6 +2085,201 @@ async def test_fallback_list_format(self, tmp_path): assert "Attention" in att_text +class TestInsertMode: + """insert_mode="fail-fast"/"fail-at-end" turn a partial compile (one or + more failed concept/entity generations) into a raised + ConceptCompilationError instead of a silently-partial "success"; see + ``_compile_concepts``'s ``insert_mode`` docstring.""" + + def _setup_wiki(self, tmp_path): + wiki = tmp_path / "wiki" + (wiki / "summaries").mkdir(parents=True) + (wiki / "concepts").mkdir(parents=True) + (wiki / "index.md").write_text( + "# Index\n\n## Documents\n\n## Concepts\n", + encoding="utf-8", + ) + (tmp_path / "raw").mkdir(exist_ok=True) + (tmp_path / "raw" / "test-doc.pdf").write_bytes(b"fake") + return wiki + + @staticmethod + def _selective_acompletion(*args, **kwargs): + """Succeed for "concept-a", raise for "concept-b" — keyed off the + concept title embedded in the page-generation prompt (see + ``_CONCEPT_PAGE_USER``) rather than call order, since concurrent + tasks don't guarantee a fixed completion order.""" + messages = kwargs.get("messages") or (args[1] if len(args) > 1 else []) + # Only the page-specific user message (appended last by _gen_create/ + # _gen_update) carries the concept title — the earlier messages + # (system/doc/summary/known-targets) are shared cached context common + # to every concept in this batch and mention both concept names. + last_content = messages[-1]["content"] if messages else "" + if "concept-b" in last_content: + raise RuntimeError("boom: concept-b generation failed") + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = json.dumps( + {"brief": "b", "content": "# Concept A\n\nBody."} + ) + mock_resp.usage = MagicMock(prompt_tokens=100, completion_tokens=50) + mock_resp.usage.prompt_tokens_details = None + return mock_resp + + def _plan_response(self): + return json.dumps( + { + "create": [ + {"name": "concept-a", "title": "concept-a"}, + {"name": "concept-b", "title": "concept-b"}, + ], + "update": [], + "related": [], + } + ) + + @pytest.mark.asyncio + async def test_normal_mode_keeps_partial_success_silent(self, tmp_path): + """Default/omitted insert_mode: unchanged behavior — no exception, + the succeeded concept is written, the failed one is just logged.""" + wiki = self._setup_wiki(tmp_path) + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock( + side_effect=_mock_completion([self._plan_response()]) + ) + mock_litellm.acompletion = AsyncMock(side_effect=self._selective_acompletion) + await _compile_concepts( + wiki, + tmp_path, + "gpt-4o-mini", + {"role": "system", "content": "s"}, + {"role": "user", "content": "d"}, + "summary", + "test-doc", + 5, + insert_mode="normal", + ) + assert (wiki / "concepts" / "concept-a.md").exists() + assert not (wiki / "concepts" / "concept-b.md").exists() + + @pytest.mark.asyncio + async def test_fail_fast_raises_and_writes_nothing(self, tmp_path): + """insert_mode="fail-fast": raises ConceptCompilationError and skips + the write phase entirely — even the concept that already succeeded + must not be written, since the caller rolls back this whole add.""" + from openkb.agent.compiler import ConceptCompilationError + + wiki = self._setup_wiki(tmp_path) + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock( + side_effect=_mock_completion([self._plan_response()]) + ) + mock_litellm.acompletion = AsyncMock(side_effect=self._selective_acompletion) + with pytest.raises(ConceptCompilationError): + await _compile_concepts( + wiki, + tmp_path, + "gpt-4o-mini", + {"role": "system", "content": "s"}, + {"role": "user", "content": "d"}, + "summary", + "test-doc", + 5, + insert_mode="fail-fast", + ) + assert not (wiki / "concepts" / "concept-a.md").exists() + assert not (wiki / "concepts" / "concept-b.md").exists() + + @pytest.mark.asyncio + async def test_fail_at_end_writes_then_raises(self, tmp_path): + """insert_mode="fail-at-end": every planned concept is attempted (so + the succeeded one IS written, unlike fail-fast) before + ConceptCompilationError is raised at the end — the caller's mutation + rollback is what discards the write, not _compile_concepts itself.""" + from openkb.agent.compiler import ConceptCompilationError + + wiki = self._setup_wiki(tmp_path) + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock( + side_effect=_mock_completion([self._plan_response()]) + ) + mock_litellm.acompletion = AsyncMock(side_effect=self._selective_acompletion) + with pytest.raises(ConceptCompilationError): + await _compile_concepts( + wiki, + tmp_path, + "gpt-4o-mini", + {"role": "system", "content": "s"}, + {"role": "user", "content": "d"}, + "summary", + "test-doc", + 5, + insert_mode="fail-at-end", + ) + assert (wiki / "concepts" / "concept-a.md").exists() + assert not (wiki / "concepts" / "concept-b.md").exists() + + @pytest.mark.asyncio + async def test_fail_at_end_no_failures_does_not_raise(self, tmp_path): + """A fully successful compile under insert_mode="fail-at-end" behaves + exactly like "normal" — the strict check only fires on an actual + failure.""" + wiki = self._setup_wiki(tmp_path) + plan_response = json.dumps( + {"create": [{"name": "concept-a", "title": "concept-a"}], "update": [], "related": []} + ) + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock(side_effect=_mock_completion([plan_response])) + mock_litellm.acompletion = AsyncMock(side_effect=self._selective_acompletion) + await _compile_concepts( + wiki, + tmp_path, + "gpt-4o-mini", + {"role": "system", "content": "s"}, + {"role": "user", "content": "d"}, + "summary", + "test-doc", + 5, + insert_mode="fail-at-end", + ) + assert (wiki / "concepts" / "concept-a.md").exists() + + @pytest.mark.asyncio + async def test_compile_short_doc_reads_insert_mode_from_kb_config(self, tmp_path): + """End-to-end wiring check: compile_short_doc reads insert_mode from + the KB's config.yaml (via resolve_effective_config) and threads it + into _compile_concepts — no CLI-level plumbing needed.""" + from openkb.agent.compiler import ConceptCompilationError + + wiki = tmp_path / "wiki" + (wiki / "sources").mkdir(parents=True) + (wiki / "summaries").mkdir(parents=True) + (wiki / "concepts").mkdir(parents=True) + (wiki / "index.md").write_text( + "# Index\n\n## Documents\n\n## Concepts\n", + encoding="utf-8", + ) + source_path = wiki / "sources" / "test-doc.md" + source_path.write_text("# Test Doc\n\nContent.", encoding="utf-8") + openkb_dir = tmp_path / ".openkb" + openkb_dir.mkdir() + (openkb_dir / "config.yaml").write_text("insert_mode: fail-fast\n", encoding="utf-8") + (tmp_path / "raw").mkdir() + (tmp_path / "raw" / "test-doc.pdf").write_bytes(b"fake") + + summary_response = json.dumps({"description": "d", "content": "# Summary\n\nContent."}) + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock( + side_effect=_mock_completion([summary_response, self._plan_response()]) + ) + mock_litellm.acompletion = AsyncMock(side_effect=self._selective_acompletion) + with pytest.raises(ConceptCompilationError): + await compile_short_doc("test-doc", source_path, tmp_path, "gpt-4o-mini") + # fail-fast: neither concept must have been written. + assert not (wiki / "concepts" / "concept-a.md").exists() + assert not (wiki / "concepts" / "concept-b.md").exists() + + class TestBriefIntegration: @pytest.mark.asyncio async def test_short_doc_briefs_in_index_and_frontmatter(self, tmp_path): diff --git a/tests/test_config.py b/tests/test_config.py index 65572d6b9..c4f4f3905 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ from openkb.config import ( DEFAULT_CONFIG, GLOBAL_SCALAR_KEYS, + VALID_INSERT_MODES, get_extra_headers, get_parallel_tool_calls, get_timeout, @@ -16,6 +17,7 @@ resolve_effective_config, resolve_extra_headers, resolve_init_kb_dir, + resolve_insert_mode, resolve_litellm_settings, resolve_model_settings, resolve_parallel_tool_calls, @@ -196,6 +198,23 @@ def test_resolve_concurrency_none_is_silent(caplog): assert caplog.text == "" +def test_resolve_insert_mode_absent_is_normal(): + assert resolve_insert_mode({}) == "normal" + + +def test_resolve_insert_mode_valid_values(): + assert set(VALID_INSERT_MODES) == {"normal", "fail-fast", "fail-at-end"} + for mode in VALID_INSERT_MODES: + assert resolve_insert_mode({"insert_mode": mode}) == mode + + +def test_resolve_insert_mode_rejects_invalid(caplog): + with caplog.at_level(logging.WARNING, logger="openkb.config"): + result = resolve_insert_mode({"insert_mode": "yolo"}) + assert result == "normal" + assert "insert_mode" in caplog.text + + def test_load_missing_file_returns_defaults(tmp_path): missing = tmp_path / "nonexistent" / "config.yaml" config = load_config(missing)