Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)**.
Expand Down
4 changes: 4 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
37 changes: 37 additions & 0 deletions openkb/agent/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions openkb/api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions openkb/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -502,6 +509,7 @@ def load_global_config() -> dict[str, Any]:
"model",
"language",
"pageindex_threshold",
"max_doc_chars",
"entity_types",
)

Expand Down
146 changes: 146 additions & 0 deletions tests/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand All @@ -16,6 +17,7 @@
_backlink_summary_entities,
_compile_concepts,
_filter_entity_items,
_maybe_truncate_doc,
_parse_entities_plan,
_parse_json,
_prepend_source_to_frontmatter,
Expand Down Expand Up @@ -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