feat: add source facts token limits to consolidation and recall (#509)
* feat: add source facts token limits to consolidation and recall
- Add two new configurable (per-bank) parameters:
- consolidation_source_facts_max_tokens: total token budget for source
facts across all observations in the consolidation prompt (-1 = unlimited)
- consolidation_source_facts_max_tokens_per_observation: per-observation
cap so each observation gets a fair share of source facts (-1 = unlimited,
default 256)
- Both are also exposed as recall API parameters via SourceFactsIncludeOptions
(max_tokens and max_tokens_per_observation)
- Consolidation now uses resolve_full_config to respect bank-level overrides
- Improve consolidation prompt: temporal metadata (occurred_start=, | Involving:)
is now clearly separated from observation text, with a concrete example showing
the expected synthesis style and explicit rules not to copy raw fact lines
- Add tests for recall source facts capping and consolidation config forwarding
- Expose all three new fields in the control plane bank config UI
- Document new env vars in configuration.md
- Regenerate OpenAPI spec and all SDK clients
* fix: reorder observations UI fields and rename Label Groups to Entity Labels
* fix: revert Entities section title (only rename inner label)
* doc: add consolidation source facts and batch size fields to memory-banks docs
This commit is contained in:
parent
1d17dea2f1
commit
5d05962db0
17 changed files with 509 additions and 37 deletions
|
|
@ -100,7 +100,12 @@ class ChunkIncludeOptions(BaseModel):
|
|||
class SourceFactsIncludeOptions(BaseModel):
|
||||
"""Options for including source facts for observation-type results."""
|
||||
|
||||
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
|
||||
max_tokens: int = Field(
|
||||
default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)"
|
||||
)
|
||||
max_tokens_per_observation: int = Field(
|
||||
default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)"
|
||||
)
|
||||
|
||||
|
||||
class IncludeOptions(BaseModel):
|
||||
|
|
@ -2224,6 +2229,9 @@ def _register_routes(app: FastAPI):
|
|||
# Determine source facts inclusion settings
|
||||
include_source_facts = request.include.source_facts is not None
|
||||
max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096
|
||||
max_source_facts_tokens_per_observation = (
|
||||
request.include.source_facts.max_tokens_per_observation if include_source_facts else -1
|
||||
)
|
||||
|
||||
pre_recall = time.time() - handler_start
|
||||
# Run recall with tracing (record metrics)
|
||||
|
|
@ -2245,6 +2253,7 @@ def _register_routes(app: FastAPI):
|
|||
max_chunk_tokens=max_chunk_tokens,
|
||||
include_source_facts=include_source_facts,
|
||||
max_source_facts_tokens=max_source_facts_tokens,
|
||||
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
|
||||
request_context=request_context,
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
|
|
|
|||
|
|
@ -292,6 +292,10 @@ ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
|
|||
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
|
||||
ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
|
||||
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
|
||||
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS"
|
||||
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
|
||||
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
|
||||
)
|
||||
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
|
||||
|
||||
# Webhook configuration (global, static - server-level only)
|
||||
|
|
@ -446,6 +450,12 @@ DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
|
|||
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
|
||||
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
|
||||
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
|
||||
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = (
|
||||
-1
|
||||
) # Total token budget for source facts in consolidation recall (-1 = unlimited)
|
||||
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
|
||||
256 # Max tokens of source facts per observation in consolidation prompt (-1 = unlimited)
|
||||
)
|
||||
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
|
||||
|
||||
# Database migrations
|
||||
|
|
@ -712,6 +722,8 @@ class HindsightConfig:
|
|||
consolidation_batch_size: int
|
||||
consolidation_llm_batch_size: int
|
||||
consolidation_max_tokens: int
|
||||
consolidation_source_facts_max_tokens: int
|
||||
consolidation_source_facts_max_tokens_per_observation: int
|
||||
observations_mission: str | None
|
||||
|
||||
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
|
||||
|
|
@ -812,6 +824,9 @@ class HindsightConfig:
|
|||
"entities_allow_free_form",
|
||||
# Consolidation settings
|
||||
"enable_observations",
|
||||
"consolidation_llm_batch_size",
|
||||
"consolidation_source_facts_max_tokens",
|
||||
"consolidation_source_facts_max_tokens_per_observation",
|
||||
"observations_mission",
|
||||
# Reflect settings
|
||||
"reflect_mission",
|
||||
|
|
@ -1162,6 +1177,15 @@ class HindsightConfig:
|
|||
consolidation_max_tokens=int(
|
||||
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
|
||||
),
|
||||
consolidation_source_facts_max_tokens=int(
|
||||
os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS))
|
||||
),
|
||||
consolidation_source_facts_max_tokens_per_observation=int(
|
||||
os.getenv(
|
||||
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION,
|
||||
str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION),
|
||||
)
|
||||
),
|
||||
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
|
||||
entity_labels=None,
|
||||
entities_allow_free_form=True,
|
||||
|
|
|
|||
|
|
@ -913,10 +913,9 @@ async def _find_related_observations(
|
|||
"""
|
||||
# Use recall to find related observations with token budget
|
||||
# max_tokens naturally limits how many observations are returned
|
||||
from ...config import get_config
|
||||
from ...tracing import get_tracer, is_tracing_enabled
|
||||
|
||||
config = get_config()
|
||||
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
|
@ -941,7 +940,8 @@ async def _find_related_observations(
|
|||
tags=tags, # Filter by source memory's tags
|
||||
tags_match=tags_match, # Use strict matching for security
|
||||
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
|
||||
max_source_facts_tokens=-1, # No token limit — we need all source facts for consolidation
|
||||
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
|
||||
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
_quiet=True, # Suppress logging
|
||||
)
|
||||
finally:
|
||||
|
|
@ -1005,14 +1005,17 @@ async def _consolidate_batch_with_llm(
|
|||
observations_text = "[]"
|
||||
|
||||
def _fact_line(m: dict[str, Any]) -> str:
|
||||
parts = [f"[{m['id']}] {m['text']}"]
|
||||
text = f"[{m['id']}] {m['text']}"
|
||||
temporal_parts = []
|
||||
if m.get("occurred_start"):
|
||||
parts.append(f"occurred_start={m['occurred_start']}")
|
||||
temporal_parts.append(f"occurred_start={m['occurred_start']}")
|
||||
if m.get("occurred_end"):
|
||||
parts.append(f"occurred_end={m['occurred_end']}")
|
||||
temporal_parts.append(f"occurred_end={m['occurred_end']}")
|
||||
if m.get("mentioned_at"):
|
||||
parts.append(f"mentioned_at={m['mentioned_at']}")
|
||||
return " | ".join(parts)
|
||||
temporal_parts.append(f"mentioned_at={m['mentioned_at']}")
|
||||
if temporal_parts:
|
||||
text += f" ({', '.join(temporal_parts)})"
|
||||
return text
|
||||
|
||||
facts_lines = "\n".join(_fact_line(m) for m in memories)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,25 @@ Compare the facts against existing observations:
|
|||
_BATCH_OUTPUT_FORMAT = """
|
||||
Output a JSON object with three arrays.
|
||||
|
||||
Example (showing the required UUID format for all IDs):
|
||||
{{"creates": [{{"text": "Alice lives in Berlin", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
|
||||
## EXAMPLE
|
||||
|
||||
Input facts:
|
||||
[a1b2c3d4-e5f6-7890-abcd-ef1234567890] Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)
|
||||
[b2c3d4e5-f6a7-8901-bcde-f12345678901] Alice said she's exhausted from the project deadlines | Involving: Alice (occurred_start=2024-01-20, mentioned_at=2024-01-20)
|
||||
|
||||
Good observation text — clean prose, no metadata, each fact tracked distinctly:
|
||||
"Alice works long hours, often past midnight."
|
||||
"Alice feels exhausted from project deadlines."
|
||||
|
||||
Bad observation text — NEVER do this (verbatim copy of fact text with metadata):
|
||||
"Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)"
|
||||
|
||||
Observation text rules:
|
||||
- Write clean prose — NEVER copy raw fact lines or their metadata (temporal fields, "Involving:", "When:" labels, UUIDs).
|
||||
- Parenthesized metadata like (occurred_start=...) and pipe-separated labels like "| Involving: ..." are fact formatting — strip them entirely from observation text.
|
||||
- How many observations to create and how much to aggregate is driven by the MISSION above.
|
||||
|
||||
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]}}, {{"text": "Alice feels exhausted from project deadlines.", "source_fact_ids": ["b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
|
||||
"updates": [{{"text": "Alice works at Acme Corp as a senior engineer", "observation_id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
|
||||
"deletes": [{{"observation_id": "e5f6a7b8-c9d0-1234-efab-345678901234"}}]}}
|
||||
|
||||
|
|
|
|||
|
|
@ -2237,6 +2237,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
max_chunk_tokens: int = 8192,
|
||||
include_source_facts: bool = False,
|
||||
max_source_facts_tokens: int = 4096,
|
||||
max_source_facts_tokens_per_observation: int = -1,
|
||||
request_context: "RequestContext",
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
|
|
@ -2378,6 +2379,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
quiet=_quiet,
|
||||
include_source_facts=include_source_facts,
|
||||
max_source_facts_tokens=max_source_facts_tokens,
|
||||
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
|
||||
)
|
||||
break # Success - exit retry loop
|
||||
except Exception as e:
|
||||
|
|
@ -2504,6 +2506,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
quiet: bool = False,
|
||||
include_source_facts: bool = False,
|
||||
max_source_facts_tokens: int = 4096,
|
||||
max_source_facts_tokens_per_observation: int = -1,
|
||||
) -> RecallResultModel:
|
||||
"""
|
||||
Search implementation with modular retrieval and reranking.
|
||||
|
|
@ -3125,18 +3128,9 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
|
||||
encoding = _get_tiktoken_encoding()
|
||||
source_facts_dict = {}
|
||||
total_source_tokens = 0
|
||||
for sid in source_ids_ordered:
|
||||
if sid not in source_row_by_id:
|
||||
continue
|
||||
r = source_row_by_id[sid]
|
||||
fact_tokens = len(encoding.encode(r["text"]))
|
||||
if (
|
||||
max_source_facts_tokens >= 0
|
||||
and total_source_tokens + fact_tokens > max_source_facts_tokens
|
||||
):
|
||||
break
|
||||
source_facts_dict[sid] = MemoryFact(
|
||||
|
||||
def _make_source_fact(sid: str, r: Any) -> MemoryFact:
|
||||
return MemoryFact(
|
||||
id=sid,
|
||||
text=r["text"],
|
||||
fact_type=r["fact_type"],
|
||||
|
|
@ -3148,7 +3142,37 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
|
||||
tags=r["tags"] or None,
|
||||
)
|
||||
total_source_tokens += fact_tokens
|
||||
|
||||
if max_source_facts_tokens_per_observation >= 0:
|
||||
# Per-observation capping: each observation independently selects
|
||||
# source facts up to its token budget.
|
||||
for obs_id, sids in source_fact_ids_by_obs.items():
|
||||
obs_tokens = 0
|
||||
for sid in sids:
|
||||
if sid not in source_row_by_id:
|
||||
continue
|
||||
r = source_row_by_id[sid]
|
||||
fact_tokens = len(encoding.encode(r["text"]))
|
||||
if obs_tokens + fact_tokens > max_source_facts_tokens_per_observation:
|
||||
break
|
||||
obs_tokens += fact_tokens
|
||||
if sid not in source_facts_dict:
|
||||
source_facts_dict[sid] = _make_source_fact(sid, r)
|
||||
else:
|
||||
# Global budget: fill in order of first appearance until exhausted.
|
||||
total_source_tokens = 0
|
||||
for sid in source_ids_ordered:
|
||||
if sid not in source_row_by_id:
|
||||
continue
|
||||
r = source_row_by_id[sid]
|
||||
fact_tokens = len(encoding.encode(r["text"]))
|
||||
if (
|
||||
max_source_facts_tokens >= 0
|
||||
and total_source_tokens + fact_tokens > max_source_facts_tokens
|
||||
):
|
||||
break
|
||||
source_facts_dict[sid] = _make_source_fact(sid, r)
|
||||
total_source_tokens += fact_tokens
|
||||
|
||||
# Get entities for each fact if include_entities is requested
|
||||
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
|
||||
|
|
|
|||
|
|
@ -278,6 +278,8 @@ def main():
|
|||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
|
||||
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
observations_mission=config.observations_mission,
|
||||
entity_labels=config.entity_labels,
|
||||
entities_allow_free_form=config.entities_allow_free_form,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests
|
|||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.consolidation.consolidator import (
|
||||
_aggregate_source_fields,
|
||||
_find_related_observations,
|
||||
run_consolidation_job,
|
||||
)
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
|
|
@ -2418,3 +2420,81 @@ class TestAggregateSourceFields:
|
|||
assert agg.occurred_end == d
|
||||
assert agg.mentioned_at == d
|
||||
assert agg.tags == ["x"]
|
||||
|
||||
|
||||
class TestConsolidationSourceFactsConfig:
|
||||
"""Tests that consolidation uses the source_facts token config when calling recall."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations(self):
|
||||
config = _get_raw_config()
|
||||
original = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_passes_source_facts_max_tokens_to_recall(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""consolidation_source_facts_max_tokens from config is forwarded to recall_async."""
|
||||
bank_id = f"test-sf-config-total-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
raw = _get_raw_config()
|
||||
fake_config = type(raw)(**{
|
||||
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
|
||||
"consolidation_source_facts_max_tokens": 999,
|
||||
"consolidation_source_facts_max_tokens_per_observation": -1,
|
||||
})
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
|
||||
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
|
||||
):
|
||||
await _find_related_observations(
|
||||
memory_engine=memory,
|
||||
bank_id=bank_id,
|
||||
query="test query",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert mock_recall.called
|
||||
_, kwargs = mock_recall.call_args
|
||||
assert kwargs.get("max_source_facts_tokens") == 999
|
||||
assert kwargs.get("max_source_facts_tokens_per_observation") == -1
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_passes_source_facts_per_obs_tokens_to_recall(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""consolidation_source_facts_max_tokens_per_observation from config is forwarded to recall_async."""
|
||||
bank_id = f"test-sf-config-per-obs-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
raw = _get_raw_config()
|
||||
fake_config = type(raw)(**{
|
||||
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
|
||||
"consolidation_source_facts_max_tokens": -1,
|
||||
"consolidation_source_facts_max_tokens_per_observation": 128,
|
||||
})
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
|
||||
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
|
||||
):
|
||||
await _find_related_observations(
|
||||
memory_engine=memory,
|
||||
bank_id=bank_id,
|
||||
query="test query",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert mock_recall.called
|
||||
_, kwargs = mock_recall.call_args
|
||||
assert kwargs.get("max_source_facts_tokens") == -1
|
||||
assert kwargs.get("max_source_facts_tokens_per_observation") == 128
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ async def test_hierarchical_fields_categorization():
|
|||
assert "retain_custom_instructions" in configurable
|
||||
assert "retain_chunk_size" in configurable
|
||||
assert "enable_observations" in configurable
|
||||
assert "consolidation_llm_batch_size" in configurable
|
||||
assert "consolidation_source_facts_max_tokens" in configurable
|
||||
assert "consolidation_source_facts_max_tokens_per_observation" in configurable
|
||||
assert "observations_mission" in configurable
|
||||
assert "reflect_mission" in configurable
|
||||
assert "disposition_skepticism" in configurable
|
||||
|
|
@ -86,7 +89,7 @@ async def test_hierarchical_fields_categorization():
|
|||
assert "entity_labels" in configurable
|
||||
|
||||
# Verify count is correct
|
||||
assert len(configurable) == 14
|
||||
assert len(configurable) == 17
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
|
|
|
|||
170
hindsight-api/tests/test_source_facts_tokens.py
Normal file
170
hindsight-api/tests/test_source_facts_tokens.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Tests for source_facts token limiting in recall.
|
||||
|
||||
Covers:
|
||||
- max_source_facts_tokens: total token budget across all source facts
|
||||
- max_source_facts_tokens_per_observation: per-observation cap
|
||||
|
||||
Both parameters are tested at the recall_async level and verified to produce
|
||||
fewer source facts when the budget is tight vs. unlimited.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
config = _get_raw_config()
|
||||
original = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original
|
||||
|
||||
|
||||
async def _setup_bank_with_observations(memory, bank_id, request_context):
|
||||
"""Retain several memories and trigger consolidation to produce observations with source facts."""
|
||||
contents = [
|
||||
"Alice is a software engineer who loves Python programming.",
|
||||
"Alice has been working at TechCorp for 5 years.",
|
||||
"Alice recently completed a machine learning certification course.",
|
||||
"Alice mentors junior developers on the team.",
|
||||
"Alice prefers functional programming patterns in her code.",
|
||||
]
|
||||
for content in contents:
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=content,
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestRecallSourceFactsPerObservationCap:
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_observation_cap_reduces_source_facts(self, memory, request_context):
|
||||
"""A tight per-observation token cap should return fewer source facts than unlimited."""
|
||||
bank_id = "test-sf-per-obs-cap"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
result_limited = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens_per_observation=1, # Effectively cuts all source facts
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result_unlimited = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens_per_observation=-1,
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
|
||||
limited_count = len(result_limited.source_facts) if result_limited.source_facts else 0
|
||||
|
||||
if unlimited_count > 0:
|
||||
assert limited_count <= unlimited_count, (
|
||||
f"Per-observation cap should yield fewer source facts ({limited_count} <= {unlimited_count})"
|
||||
)
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_observation_cap_does_not_mix_between_observations(self, memory, request_context):
|
||||
"""Each observation's source facts are capped independently — not as a shared pool."""
|
||||
bank_id = "test-sf-per-obs-independent"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
# With a generous per-observation limit each observation can have facts;
|
||||
# with a global limit of 1 token the first observation would consume the whole budget.
|
||||
result_per_obs = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=4096, # large global budget
|
||||
max_source_facts_tokens_per_observation=512, # reasonable per-obs limit
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Should not raise; source_facts may be populated for multiple observations
|
||||
assert result_per_obs.source_facts is not None or len(result_per_obs.results) == 0
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestRecallSourceFactsTotalBudget:
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_budget_limits_source_facts(self, memory, request_context):
|
||||
"""A tight total token budget should return fewer source facts than unlimited."""
|
||||
bank_id = "test-sf-total-budget"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
result_tight = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=1, # Effectively cuts all source facts
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result_unlimited = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=-1,
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
|
||||
tight_count = len(result_tight.source_facts) if result_tight.source_facts else 0
|
||||
|
||||
if unlimited_count > 0:
|
||||
assert tight_count <= unlimited_count, (
|
||||
f"Total budget should yield fewer source facts ({tight_count} <= {unlimited_count})"
|
||||
)
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_source_facts_without_flag(self, memory, request_context):
|
||||
"""source_facts should be None when include_source_facts is not set."""
|
||||
bank_id = "test-sf-no-flag"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=False, # default
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result.source_facts is None or len(result.source_facts) == 0
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
|
@ -4591,9 +4591,15 @@ components:
|
|||
properties:
|
||||
max_tokens:
|
||||
default: 4096
|
||||
description: Maximum tokens for source facts
|
||||
description: Maximum total tokens for source facts across all observations
|
||||
(-1 = unlimited)
|
||||
title: Max Tokens
|
||||
type: integer
|
||||
max_tokens_per_observation:
|
||||
default: -1
|
||||
description: Maximum tokens of source facts per observation (-1 = unlimited)
|
||||
title: Max Tokens Per Observation
|
||||
type: integer
|
||||
title: SourceFactsIncludeOptions
|
||||
TagItem:
|
||||
description: Single tag with usage count.
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ var _ MappedNullable = &SourceFactsIncludeOptions{}
|
|||
|
||||
// SourceFactsIncludeOptions Options for including source facts for observation-type results.
|
||||
type SourceFactsIncludeOptions struct {
|
||||
// Maximum tokens for source facts
|
||||
// Maximum total tokens for source facts across all observations (-1 = unlimited)
|
||||
MaxTokens *int32 `json:"max_tokens,omitempty"`
|
||||
// Maximum tokens of source facts per observation (-1 = unlimited)
|
||||
MaxTokensPerObservation *int32 `json:"max_tokens_per_observation,omitempty"`
|
||||
}
|
||||
|
||||
// NewSourceFactsIncludeOptions instantiates a new SourceFactsIncludeOptions object
|
||||
|
|
@ -31,6 +33,8 @@ func NewSourceFactsIncludeOptions() *SourceFactsIncludeOptions {
|
|||
this := SourceFactsIncludeOptions{}
|
||||
var maxTokens int32 = 4096
|
||||
this.MaxTokens = &maxTokens
|
||||
var maxTokensPerObservation int32 = -1
|
||||
this.MaxTokensPerObservation = &maxTokensPerObservation
|
||||
return &this
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +45,8 @@ func NewSourceFactsIncludeOptionsWithDefaults() *SourceFactsIncludeOptions {
|
|||
this := SourceFactsIncludeOptions{}
|
||||
var maxTokens int32 = 4096
|
||||
this.MaxTokens = &maxTokens
|
||||
var maxTokensPerObservation int32 = -1
|
||||
this.MaxTokensPerObservation = &maxTokensPerObservation
|
||||
return &this
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +82,38 @@ func (o *SourceFactsIncludeOptions) SetMaxTokens(v int32) {
|
|||
o.MaxTokens = &v
|
||||
}
|
||||
|
||||
// GetMaxTokensPerObservation returns the MaxTokensPerObservation field value if set, zero value otherwise.
|
||||
func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservation() int32 {
|
||||
if o == nil || IsNil(o.MaxTokensPerObservation) {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
return *o.MaxTokensPerObservation
|
||||
}
|
||||
|
||||
// GetMaxTokensPerObservationOk returns a tuple with the MaxTokensPerObservation field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservationOk() (*int32, bool) {
|
||||
if o == nil || IsNil(o.MaxTokensPerObservation) {
|
||||
return nil, false
|
||||
}
|
||||
return o.MaxTokensPerObservation, true
|
||||
}
|
||||
|
||||
// HasMaxTokensPerObservation returns a boolean if a field has been set.
|
||||
func (o *SourceFactsIncludeOptions) HasMaxTokensPerObservation() bool {
|
||||
if o != nil && !IsNil(o.MaxTokensPerObservation) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetMaxTokensPerObservation gets a reference to the given int32 and assigns it to the MaxTokensPerObservation field.
|
||||
func (o *SourceFactsIncludeOptions) SetMaxTokensPerObservation(v int32) {
|
||||
o.MaxTokensPerObservation = &v
|
||||
}
|
||||
|
||||
func (o SourceFactsIncludeOptions) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
|
|
@ -89,6 +127,9 @@ func (o SourceFactsIncludeOptions) ToMap() (map[string]interface{}, error) {
|
|||
if !IsNil(o.MaxTokens) {
|
||||
toSerialize["max_tokens"] = o.MaxTokens
|
||||
}
|
||||
if !IsNil(o.MaxTokensPerObservation) {
|
||||
toSerialize["max_tokens_per_observation"] = o.MaxTokensPerObservation
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ class SourceFactsIncludeOptions(BaseModel):
|
|||
"""
|
||||
Options for including source facts for observation-type results.
|
||||
""" # noqa: E501
|
||||
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum tokens for source facts")
|
||||
__properties: ClassVar[List[str]] = ["max_tokens"]
|
||||
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)")
|
||||
max_tokens_per_observation: Optional[StrictInt] = Field(default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)")
|
||||
__properties: ClassVar[List[str]] = ["max_tokens", "max_tokens_per_observation"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
|
|
@ -80,7 +81,8 @@ class SourceFactsIncludeOptions(BaseModel):
|
|||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096
|
||||
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096,
|
||||
"max_tokens_per_observation": obj.get("max_tokens_per_observation") if obj.get("max_tokens_per_observation") is not None else -1
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
|
|
|||
|
|
@ -1960,9 +1960,15 @@ export type SourceFactsIncludeOptions = {
|
|||
/**
|
||||
* Max Tokens
|
||||
*
|
||||
* Maximum tokens for source facts
|
||||
* Maximum total tokens for source facts across all observations (-1 = unlimited)
|
||||
*/
|
||||
max_tokens?: number;
|
||||
/**
|
||||
* Max Tokens Per Observation
|
||||
*
|
||||
* Maximum tokens of source facts per observation (-1 = unlimited)
|
||||
*/
|
||||
max_tokens_per_observation?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ type RetainEdits = {
|
|||
|
||||
type ObservationsEdits = {
|
||||
enable_observations: boolean | null;
|
||||
consolidation_llm_batch_size: number | null;
|
||||
consolidation_source_facts_max_tokens: number | null;
|
||||
consolidation_source_facts_max_tokens_per_observation: number | null;
|
||||
observations_mission: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -143,6 +146,10 @@ function retainSlice(config: Record<string, any>): RetainEdits {
|
|||
function observationsSlice(config: Record<string, any>): ObservationsEdits {
|
||||
return {
|
||||
enable_observations: config.enable_observations ?? null,
|
||||
consolidation_llm_batch_size: config.consolidation_llm_batch_size ?? null,
|
||||
consolidation_source_facts_max_tokens: config.consolidation_source_facts_max_tokens ?? null,
|
||||
consolidation_source_facts_max_tokens_per_observation:
|
||||
config.consolidation_source_facts_max_tokens_per_observation ?? null,
|
||||
observations_mission: config.observations_mission ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -489,7 +496,7 @@ export function BankConfigView() {
|
|||
>
|
||||
<FieldRow
|
||||
label="Free Form Entities"
|
||||
description="Extract regular named entities (people, places, concepts) alongside label groups. Disable to restrict extraction to label groups only."
|
||||
description="Extract regular named entities (people, places, concepts) alongside entity labels. Disable to restrict extraction to entity labels only."
|
||||
>
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<Label
|
||||
|
|
@ -550,6 +557,64 @@ export function BankConfigView() {
|
|||
placeholder="e.g. Observations are stable facts about people and projects. Always include preferences, skills, and recurring patterns. Ignore one-off events and ephemeral state."
|
||||
rows={3}
|
||||
/>
|
||||
<FieldRow
|
||||
label="LLM Batch Size"
|
||||
description="Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls at the cost of larger prompts. Leave blank to use the server default."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={64}
|
||||
value={observationsEdits.consolidation_llm_batch_size ?? ""}
|
||||
onChange={(e) =>
|
||||
setObservationsEdits((prev) => ({
|
||||
...prev,
|
||||
consolidation_llm_batch_size: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Server default"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label="Source Facts Max Tokens"
|
||||
description="Total token budget for source facts included with observations during consolidation. -1 = unlimited."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
value={observationsEdits.consolidation_source_facts_max_tokens ?? ""}
|
||||
onChange={(e) =>
|
||||
setObservationsEdits((prev) => ({
|
||||
...prev,
|
||||
consolidation_source_facts_max_tokens: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Server default"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label="Source Facts Max Tokens Per Observation"
|
||||
description="Per-observation token cap for source facts during consolidation. Each observation gets at most this many tokens of source facts. -1 = unlimited."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
value={observationsEdits.consolidation_source_facts_max_tokens_per_observation ?? ""}
|
||||
onChange={(e) =>
|
||||
setObservationsEdits((prev) => ({
|
||||
...prev,
|
||||
consolidation_source_facts_max_tokens_per_observation: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Server default"
|
||||
/>
|
||||
</FieldRow>
|
||||
</ConfigSection>
|
||||
|
||||
{/* Reflect Section */}
|
||||
|
|
@ -1015,7 +1080,7 @@ function EntityLabelsEditor({
|
|||
<div className="px-6 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Label Groups</p>
|
||||
<p className="text-sm font-medium">Entity Labels</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Classification labels extracted at retain time. Leave empty to disable.
|
||||
</p>
|
||||
|
|
@ -1028,7 +1093,7 @@ function EntityLabelsEditor({
|
|||
</div>
|
||||
|
||||
{value.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">No label groups defined.</p>
|
||||
<p className="text-xs text-muted-foreground italic">No entity labels defined.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -170,6 +170,18 @@ e.g. Observations are stable facts about people and projects.
|
|||
Ignore one-off events and ephemeral state.
|
||||
```
|
||||
|
||||
### consolidation_llm_batch_size
|
||||
|
||||
Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Leave unset to use the server default (`8`).
|
||||
|
||||
### consolidation_source_facts_max_tokens
|
||||
|
||||
Total token budget for source facts included with observations in the consolidation prompt. Source facts give the LLM evidence to compare new facts against existing observations. `-1` = unlimited. Leave unset to use the server default (`-1`).
|
||||
|
||||
### consolidation_source_facts_max_tokens_per_observation
|
||||
|
||||
Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts, preventing a single observation with many source facts from consuming the entire budget. `-1` = unlimited. Leave unset to use the server default (`256`).
|
||||
|
||||
See [Observations configuration](/developer/configuration#observations) for environment variable names and defaults.
|
||||
|
||||
### reflect_mission
|
||||
|
|
|
|||
|
|
@ -735,7 +735,9 @@ Observations are consolidated knowledge synthesized from facts.
|
|||
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. | `8` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Configurable per bank. | `8` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS` | Total token budget for source facts included with observations in the consolidation prompt. `-1` = unlimited. Configurable per bank. | `-1` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION` | Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts. `-1` = unlimited. Configurable per bank. | `256` |
|
||||
| `HINDSIGHT_API_OBSERVATIONS_MISSION` | What this bank should synthesise into durable observations. Replaces the built-in consolidation rules — leave unset to use the server default. | - |
|
||||
|
||||
#### Customizing observations: when to use what
|
||||
|
|
|
|||
|
|
@ -7210,8 +7210,14 @@
|
|||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens",
|
||||
"description": "Maximum tokens for source facts",
|
||||
"description": "Maximum total tokens for source facts across all observations (-1 = unlimited)",
|
||||
"default": 4096
|
||||
},
|
||||
"max_tokens_per_observation": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens Per Observation",
|
||||
"description": "Maximum tokens of source facts per observation (-1 = unlimited)",
|
||||
"default": -1
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
|
|||
Loading…
Reference in a new issue