diff --git a/plugins/KnowledgeEngine/LongTermMemory/README.md b/plugins/KnowledgeEngine/LongTermMemory/README.md index d59a45ff9..9b183c105 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/README.md +++ b/plugins/KnowledgeEngine/LongTermMemory/README.md @@ -650,3 +650,6 @@ Active sources remain active; this feature does not promise lossy compression or Existing historical summary records without lineage cannot be retroactively traced. They are not migrated automatically. Semantic drafting and explicit approval are also available through the Memory Console review panel, so processor-only deployments do not need a Pipeline command route. Document deletion invalidates dependent summaries and pending drafts for every affected memory scope before removing imported records. Deleting a knowledge base clears only that collection's pending drafts. Preview generation and collection deletion share the same collection lock, so an in-flight model preview cannot save source text after deletion completes; a preview for a removed KB is rejected before calling the model. Each model call holding that lock is capped at 30 seconds; queued callers and storage operations still follow their normal API lifecycle. + + +A reviewed summary must also fit the bounded recall record budget before publication; oversized summaries are rejected without writing an episode or hiding source facts. Reduce the consolidation candidate count and preview again. This is a character/serialization bound, not an exact token estimate. diff --git a/plugins/KnowledgeEngine/LongTermMemory/store/consolidation.py b/plugins/KnowledgeEngine/LongTermMemory/store/consolidation.py index e3111da36..05b57005c 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/store/consolidation.py +++ b/plugins/KnowledgeEngine/LongTermMemory/store/consolidation.py @@ -10,6 +10,7 @@ from datetime import datetime, timezone from langbot_plugin.api.entities.builtin.provider.message import Message from store.episode_provenance import normalize_provenance, read_provenance +from store.recall import select_memories MODEL_TIMEOUT_SECONDS = 30 PROPOSAL_PREFIX = "ltm.consolidation.proposal." @@ -204,6 +205,9 @@ async def apply_summary(store, collection_id: str, embedding_model_uuid: str, us "consolidation_source_ids": json.dumps(proposal["source_ids"]), "consolidation_source_hashes": json.dumps(proposal["source_hashes"], sort_keys=True), } + record = {"id": episode_id, "metadata": meta, "document": content, "score": None, "distance": 0.0} + if not select_memories([record], "", top_k=1): + raise ValueError("Summary exceeds the recall budget; reduce consolidation candidates and preview again") vectors = await store.plugin.invoke_embedding(embedding_model_uuid, [content]) await store._writes.mutate( store.plugin, diff --git a/plugins/KnowledgeEngine/LongTermMemory/tests/store/test_semantic_consolidation.py b/plugins/KnowledgeEngine/LongTermMemory/tests/store/test_semantic_consolidation.py index 6de166400..9db32d0ff 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/tests/store/test_semantic_consolidation.py +++ b/plugins/KnowledgeEngine/LongTermMemory/tests/store/test_semantic_consolidation.py @@ -345,3 +345,20 @@ async def test_summary_verification_allows_host_added_scope_metadata(): assert result["summary_episode"]["status"] == "active" assert result["summary_episode"]["metadata"]["_langbot_workspace_uuid"] == "workspace-test" assert plugin.records["source"]["metadata"]["status"] == "active" + + +@pytest.mark.asyncio +async def test_summary_too_large_for_recall_is_not_published(): + plugin = SemanticPlugin() + add_source(plugin, "one", "First source fact") + add_source(plugin, "two", "Second source fact") + plugin.response = {"facts": [ + {"text": "a" * 2000, "source_ids": ["one"]}, + {"text": "b" * 2000, "source_ids": ["two"]}, + ]} + store = MemoryStore(plugin) + draft = await preview(store) + with pytest.raises(ValueError, match="recall budget"): + await store.apply_consolidation("kb", "emb", "scope", proposal_id=draft["proposal_id"]) + assert set(plugin.records) == {"one", "two"} + assert all(item["metadata"]["status"] == "active" for item in plugin.records.values())