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:
Nicolò Boschi 2026-03-06 13:01:33 +01:00 committed by GitHub
parent 1d17dea2f1
commit 5d05962db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 509 additions and 37 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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"}}]}}

View file

@ -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,6 +3142,36 @@ class MemoryEngine(MemoryEngineInterface):
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
tags=r["tags"] or None,
)
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

View file

@ -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,

View file

@ -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)

View file

@ -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

View 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)

View file

@ -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.

View file

@ -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
}

View file

@ -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

View file

@ -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;
};
/**

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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",