From 1b8fb497e53205bd376a95c510a516b312f149b8 Mon Sep 17 00:00:00 2001 From: Josh Hudson <313875020+hudsonwa@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:01:59 +0800 Subject: [PATCH] fix(compiler): cap short-doc source text via configurable max_doc_chars The short-doc compile path sends the whole markdown source file as a single LLM message, so an oversized doc overflows the model context and fails (issue #73). Add max_doc_chars (default 500k chars, configurable per-KB/ global and via the API PATCH) that truncates the payload with an explicit marker so the model knows the tail is missing. - config.py: add max_doc_chars to DEFAULT_CONFIG + GLOBAL_SCALAR_KEYS - api_models.py: expose max_doc_chars in the writable config schema - compiler.py: _maybe_truncate_doc helper + use in compile_short_doc - docs: config.yaml.example, README, examples/configuration - tests: unit + integration (RED->GREEN proven); 6 new tests --- README.md | 1 + config.yaml.example | 4 + examples/configuration/README.md | 5 ++ openkb/agent/compiler.py | 37 ++++++++ openkb/api_models.py | 4 + openkb/config.py | 8 ++ tests/test_compiler.py | 146 +++++++++++++++++++++++++++++++ 7 files changed, 205 insertions(+) diff --git a/README.md b/README.md index 988bebda..625dad7a 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ OpenKB settings are initialized by `openkb init` and stored in `.openkb/config.y model: gpt-5.4 # LLM model (any LiteLLM-supported provider) language: en # Wiki output language pageindex_threshold: 20 # PDF pages threshold for PageIndex +max_doc_chars: 500000 # Hard cap (chars) on source text per short-doc compile prompt (see issue #73) ``` The full settings reference — `entity_types`, OAuth providers (`chatgpt/*`, `github_copilot/*`), and LiteLLM tuning (timeouts for slow local runtimes like Ollama / LM Studio, `drop_params`, GitHub Copilot headers, install notes) — is in **[`examples/configuration/`](examples/configuration/)**. diff --git a/config.yaml.example b/config.yaml.example index 47d2fda2..0802c22e 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,6 +1,10 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider) language: en # Wiki output language pageindex_threshold: 20 # PDF pages threshold for PageIndex +# max_doc_chars: 500000 # Hard cap (chars) on the source text sent to a +# # single short-doc compile prompt. Prevents a +# # large markdown doc overflowing the model +# # context (issue #73). Lower for local models. # Optional: cap concurrent LLM calls during ingest (PageIndex indexing and # concept/entity compilation — they never overlap, so one setting covers diff --git a/examples/configuration/README.md b/examples/configuration/README.md index 25e5b3a8..7d95a47e 100644 --- a/examples/configuration/README.md +++ b/examples/configuration/README.md @@ -69,6 +69,10 @@ The file `init` writes is small; everything else is optional. This is the shippe model: gpt-5.4 # LLM model (any LiteLLM-supported provider) language: en # Wiki output language pageindex_threshold: 20 # PDF pages threshold for PageIndex +# max_doc_chars: 500000 # Hard cap (chars) on the source text sent to a +# # single short-doc compile prompt. Prevents a +# # large markdown doc overflowing the model +# # context (issue #73). Lower for local models. # Optional: cap concurrent LLM calls during ingest (PageIndex indexing and # concept/entity compilation — they never overlap, so one setting covers @@ -111,6 +115,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex | `model` | `gpt-5.4` | LLM used for all compile/query/chat work. | | `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/). | +| `max_doc_chars` | `500000` | Hard cap (characters) on the source text sent to a single short-doc compile prompt. Prevents a large markdown / text doc from overflowing the model context (which would otherwise fail or produce a truncated wiki page). Set it below your model's max input if you run local models. See issue #73. | | `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. | | `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. | diff --git a/openkb/agent/compiler.py b/openkb/agent/compiler.py index d0c9f878..0d4a399a 100644 --- a/openkb/agent/compiler.py +++ b/openkb/agent/compiler.py @@ -260,6 +260,33 @@ # --------------------------------------------------------------------------- +def _maybe_truncate_doc(content: str, max_doc_chars: int, doc_name: str = "") -> str: + """Cap ``content`` at ``max_doc_chars`` characters when it overflows. + + The short-doc compile path sends the whole source document as one LLM + message (no chunking — see issue #73). If the text is larger than the + budget the prompt would overflow the model context and fail, or the + provider would silently drop the tail. When that happens this truncates + the payload and appends an explicit marker so the model knows the tail is + missing. Documents within the budget are returned untouched (fast path). + """ + if len(content) <= max_doc_chars: + return content + logger.warning( + "Markdown doc %s is %d chars (over max_doc_chars=%d); " + "truncating the tail with an explicit marker", + doc_name, + len(content), + max_doc_chars, + ) + trunc_marker = ( + f"\n\n> **NOTE:** this document was truncated at " + f"{max_doc_chars} characters (config `max_doc_chars`). " + f"The following sections were not included." + ) + return content[:max_doc_chars] + trunc_marker + + def _cached_text(text: str) -> list[dict]: """Wrap a text payload into a content-block list with an Anthropic ephemeral cache_control marker. @@ -2225,6 +2252,16 @@ async def compile_short_doc( schema_md = get_agents_md(wiki_dir) content = source_path.read_text(encoding="utf-8") + # Issue #73 — the short-doc path sends the whole document as a single LLM + # message, so an oversized markdown file overflows the model context and + # fails (or the provider silently drops the tail). Cap the payload at the + # configured `max_doc_chars` budget and mark the truncation explicitly so + # the model knows the tail is missing and the user can tell it happened. + # Default 500k is a pure safety cap for normal docs; local-model users set + # `max_doc_chars` to fit their context window. + max_doc_chars = int(config.get("max_doc_chars", 500_000)) + content = _maybe_truncate_doc(content, max_doc_chars, doc_name) + # Base context A: system + document. cache_control marker on the doc # message creates a cache breakpoint that covers (system + doc) for # every downstream call (summary, concepts-plan, every concept page). diff --git a/openkb/api_models.py b/openkb/api_models.py index fb9d7575..c7caf2a6 100644 --- a/openkb/api_models.py +++ b/openkb/api_models.py @@ -419,6 +419,10 @@ class _KbConfigWritable(BaseModel): model: str | None = None language: str | None = None pageindex_threshold: int | None = None + # Upper bound (chars) on source text fed to a single short-doc compile + # prompt (see config.DEFAULT_CONFIG) — mirrors pageindex_threshold's scalar + # layering, so a sane default lives in code and users override per-KB/global. + max_doc_chars: int | None = None # Entity-type vocabulary for extraction. A list REPLACES the layer below; an # explicit null reverts to inherited. Values are cleaned/deduped and "other" # is always ensured at read time (config.resolve_entity_types). diff --git a/openkb/config.py b/openkb/config.py index 95ca8691..64ae6d84 100644 --- a/openkb/config.py +++ b/openkb/config.py @@ -31,6 +31,13 @@ "model": "gpt-5.4", "language": "en", "pageindex_threshold": 20, + # Upper bound (characters) on the source text fed to a *single* short-doc + # compile prompt. The short-doc path sends the whole document as one LLM + # message (no chunking — see issue #73); without a cap, a large markdown + # file overflows the model context and fails or silently truncates. This is + # a pure safety cap, set high enough to never affect normal docs; local-model + # users should lower it to fit their context window. 500k chars ≈ 125k tokens. + "max_doc_chars": 500_000, # A GLOBAL_SCALAR_KEY like the three above, so the merged `effective` dict # always carries it (the layering/`sources` logic is type-agnostic). A # global/KB list overrides it wholesale; resolve_entity_types cleans the @@ -502,6 +509,7 @@ def load_global_config() -> dict[str, Any]: "model", "language", "pageindex_threshold", + "max_doc_chars", "entity_types", ) diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 95a57cc4..0af0fc31 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -16,6 +17,7 @@ _backlink_summary_entities, _compile_concepts, _filter_entity_items, + _maybe_truncate_doc, _parse_entities_plan, _parse_json, _prepend_source_to_frontmatter, @@ -2830,3 +2832,147 @@ def test_concept_update_malformed_frontmatter_rebuilds(self, tmp_path): assert 'type: "Concept"' in text # Must have a properly closed frontmatter block (two '---' occurrences). assert text.count("---") >= 2 + + +class TestMaybeTruncateDoc: + """``_maybe_truncate_doc`` caps oversized source docs for the short-doc path. + + Issue #73: the short-doc compiler sends the whole markdown file as one LLM + message, so an oversized doc overflows the model context. The helper must + truncate over-budget docs with an explicit marker and pass through docs + within the budget untouched. + """ + + def test_under_budget_passthrough_unchanged(self): + content = "short markdown body" + assert _maybe_truncate_doc(content, 1000, "doc") == content + + def test_exactly_at_budget_passthrough(self): + content = "x" * 100 + assert _maybe_truncate_doc(content, 100, "doc") == content + + def test_over_budget_truncates_with_marker(self): + content = "A" * 200 + out = _maybe_truncate_doc(content, 50, "doc") + # Body is capped at the budget... + assert out.startswith("A" * 50) + # ...and carries an explicit marker so the model knows the tail is gone. + assert "truncated at 50 characters" in out + assert "not included" in out + # The original tail text must NOT appear in the output. + assert "A" * 100 not in out + + def test_marker_reports_configured_budget(self): + out = _maybe_truncate_doc("B" * 500, 123, "big-doc") + assert "truncated at 123 characters" in out + assert "config `max_doc_chars`" in out + + +class TestShortDocContentBudget: + """Integration: ``compile_short_doc`` respects ``max_doc_chars`` from config. + + An oversized markdown source must be truncated before it reaches the LLM + summary call, with the explicit marker visible in the user message. + """ + + def _write_kb(self, tmp_path, max_doc_chars: int) -> Path: + 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", + ) + (tmp_path / ".openkb").mkdir() + (tmp_path / ".openkb" / "config.yaml").write_text( + f"max_doc_chars: {max_doc_chars}\n", + encoding="utf-8", + ) + src = wiki / "sources" / "big.md" + return src + + @pytest.mark.asyncio + async def test_large_markdown_is_truncated_with_marker(self, tmp_path): + src = self._write_kb(tmp_path, max_doc_chars=100) + # A document comfortably over the 100-char budget, with a distinctive tail. + src.write_text("Start of document.\n" + ("T" * 500) + "\nEND_TAIL_MARKER", encoding="utf-8") + + captured: list[list[dict]] = [] + + def sync_side_effect(*args, **kwargs): + captured.append(kwargs["messages"]) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = json.dumps( + {"description": "d", "content": "summary"} + ) + mock_resp.usage = MagicMock(prompt_tokens=1, completion_tokens=1) + mock_resp.usage.prompt_tokens_details = None + return mock_resp + + async def async_side_effect(*args, **kwargs): + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = json.dumps({"brief": "c", "content": "page"}) + mock_resp.usage = MagicMock(prompt_tokens=1, completion_tokens=1) + mock_resp.usage.prompt_tokens_details = None + return mock_resp + + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock(side_effect=sync_side_effect) + mock_litellm.acompletion = AsyncMock(side_effect=async_side_effect) + await compile_short_doc("big", src, tmp_path, "gpt-4o-mini") + + # The first message sent to the LLM is the document-summary request; its + # user payload must be truncated and carry the marker. + summary_call = captured[0] + doc_user = summary_call[1] + sent_text = doc_user["content"] + # Content may be a list-of-blocks (from _cached_text) or a plain string; + # handle both the way the pipeline emits it. + if isinstance(sent_text, list): + sent_text = "".join(b.get("text", "") for b in sent_text) + assert "truncated at 100 characters" in sent_text, sent_text + # The distinctive tail is gone from the payload. + assert "END_TAIL_MARKER" not in sent_text + + @pytest.mark.asyncio + async def test_doc_within_budget_passes_through(self, tmp_path): + src = self._write_kb(tmp_path, max_doc_chars=100_000) + body = "A modest document.\n" + ("P" * 400) + src.write_text(body, encoding="utf-8") + + captured: list[list[dict]] = [] + + def sync_side_effect(*args, **kwargs): + captured.append(kwargs["messages"]) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = json.dumps( + {"description": "d", "content": "summary"} + ) + mock_resp.usage = MagicMock(prompt_tokens=1, completion_tokens=1) + mock_resp.usage.prompt_tokens_details = None + return mock_resp + + async def async_side_effect(*args, **kwargs): + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = json.dumps({"brief": "c", "content": "page"}) + mock_resp.usage = MagicMock(prompt_tokens=1, completion_tokens=1) + mock_resp.usage.prompt_tokens_details = None + return mock_resp + + with patch("openkb.agent.compiler.litellm") as mock_litellm: + mock_litellm.completion = MagicMock(side_effect=sync_side_effect) + mock_litellm.acompletion = AsyncMock(side_effect=async_side_effect) + await compile_short_doc("big", src, tmp_path, "gpt-4o-mini") + + summary_call = captured[0] + sent_text = summary_call[1]["content"] + if isinstance(sent_text, list): + sent_text = "".join(b.get("text", "") for b in sent_text) + # No truncation marker when the doc fits the budget. + assert "truncated at" not in sent_text + assert "P" * 400 in sent_text