Skip to content
Merged
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
14 changes: 14 additions & 0 deletions hindsight-api-slim/hindsight_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
ENV_MENTAL_MODEL_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES"

# Webhook configuration (global, static - server-level only)
ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL"
Expand Down Expand Up @@ -616,6 +617,12 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
# Each history entry snapshots previous_content + previous_reflect_response. Without
# a cap, sustained mental-model refresh load grows the jsonb array unboundedly until
# it crosses Postgres's hard 256MB jsonb limit and subsequent UPDATEs fail with
# SQLSTATE 54000. 50 keeps the array well under 100MB even with large reflect
# responses, while preserving enough recent history for meaningful audit / rollback.
DEFAULT_MENTAL_MODEL_HISTORY_MAX_ENTRIES = 50
DEFAULT_CONSOLIDATION_MAX_ATTEMPTS = 3 # Outer retry attempts for consolidation LLM batch calls
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = (
Expand Down Expand Up @@ -1081,6 +1088,7 @@ class HindsightConfig:
enable_observations: bool
enable_observation_history: bool
enable_mental_model_history: bool
mental_model_history_max_entries: int
consolidation_batch_size: int
consolidation_max_memories_per_round: int
consolidation_llm_batch_size: int
Expand Down Expand Up @@ -1751,6 +1759,12 @@ def from_env(cls) -> "HindsightConfig":
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
).lower()
== "true",
mental_model_history_max_entries=int(
os.getenv(
ENV_MENTAL_MODEL_HISTORY_MAX_ENTRIES,
str(DEFAULT_MENTAL_MODEL_HISTORY_MAX_ENTRIES),
)
),
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),
Expand Down
42 changes: 38 additions & 4 deletions hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -7978,20 +7978,54 @@ async def update_mental_model(
params.append(content)
param_idx += 1
updates.append("last_refreshed_at = NOW()")
# Record history entry with the previous content
# Record history entry with the previous content.
#
# Cap the array to the most recent N entries at write time
# (see HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES).
#
# Each entry stores only the slim slice of previous_reflect_response
# that consumers actually read: `based_on` (the fact references that
# backed that version). The full reflect_response can be hundreds of
# KB once `text`, fact bodies, scoring, and embeddings are included;
# storing the full payload made each UPDATE rewrite ~10-20 MB of TOAST
# per refresh, which prevented HOT updates and accumulated dead
# tuples faster than autovacuum could reclaim them. The slim shape
# keeps per-row size in the ~hundreds-of-KB range, which fits in a
# single heap page and re-enables HOT updates.
if get_config().enable_mental_model_history:
slim_reflect_response: dict[str, Any] | None = None
if previous_reflect_response is not None:
based_on = previous_reflect_response.get("based_on")
if based_on is not None:
slim_reflect_response = {"based_on": based_on}
history_entry = json.dumps(
[
{
"previous_content": previous_content,
"previous_reflect_response": previous_reflect_response,
"previous_reflect_response": slim_reflect_response,
"changed_at": datetime.now(timezone.utc).isoformat(),
}
]
)
updates.append(f"history = COALESCE(history, '[]'::jsonb) || ${param_idx}::jsonb")
params.append(history_entry)
max_entries = get_config().mental_model_history_max_entries
history_param_idx = param_idx
param_idx += 1
max_entries_param_idx = param_idx
param_idx += 1
updates.append(
"history = ("
" SELECT COALESCE(jsonb_agg(elem ORDER BY idx), '[]'::jsonb) "
" FROM jsonb_array_elements("
f" COALESCE(history, '[]'::jsonb) || ${history_param_idx}::jsonb"
" ) WITH ORDINALITY a(elem, idx) "
" WHERE idx > GREATEST("
" jsonb_array_length(COALESCE(history, '[]'::jsonb)) + 1"
f" - ${max_entries_param_idx}, 0"
" )"
")"
)
params.append(history_entry)
params.append(max_entries)
# Also update embedding (convert to string for asyncpg vector type)
embedding_text = f"{name or ''} {content}"
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
Expand Down
112 changes: 109 additions & 3 deletions hindsight-api-slim/tests/test_mental_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,16 @@ async def test_history_recorded_on_content_update(self, memory: MemoryEngine, re
async def test_history_snapshots_previous_reflect_response(
self, memory: MemoryEngine, request_context
):
"""Each history entry snapshots the reflect_response that produced previous_content."""
"""Each history entry stores only the slim {based_on: ...} slice of
previous_reflect_response.

Rationale: the only consumer of `previous_reflect_response` in history
is the control-plane UI's "based_on diff" view. Storing the full reflect
response (including `text`, fact bodies, scoring) made each UPDATE
rewrite ~10-20 MB of TOAST per refresh and prevented HOT updates. The
slim shape keeps row size bounded so HOT updates apply and dead tuples
self-clean inline.
"""
bank_id = f"test-mm-history-reflect-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)

Expand Down Expand Up @@ -728,15 +737,70 @@ async def test_history_snapshots_previous_reflect_response(

history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 2
# Most recent first: replacing v2 snapshotted rr_v1 (the reflect that produced v2).
# Most recent first: replacing v2 snapshotted rr_v1's based_on (the only slice
# the UI consumes). The bulky `text` and `mental_models` fields are dropped.
assert history[0]["previous_content"] == "v2"
assert history[0]["previous_reflect_response"] == rr_v1
assert history[0]["previous_reflect_response"] == {"based_on": rr_v1["based_on"]}
# The first update replaced v1, which had no reflect_response stored yet.
assert history[1]["previous_content"] == "v1"
assert history[1]["previous_reflect_response"] is None

await memory.delete_bank(bank_id, request_context=request_context)

async def test_history_snapshots_omit_reflect_response_when_based_on_missing(
self, memory: MemoryEngine, request_context
):
"""If a reflect_response has no `based_on` (older snapshots, malformed
payloads), the slim path stores None rather than an empty shell."""
bank_id = f"test-mm-history-no-based-on-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)

mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="v1",
request_context=request_context,
)

# reflect_response with no based_on field — will become the "previous"
# reflect_response when v3 is written, producing slim None.
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v2",
reflect_response={"text": "v1", "mental_models": []},
request_context=request_context,
)
# reflect_response with based_on={} — will become the "previous"
# reflect_response when v4 is written, producing slim {"based_on": {}}.
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v3",
reflect_response={"text": "v2", "based_on": {}},
request_context=request_context,
)
# One more update so that the based_on={} reflect_response becomes
# the *previous* state captured in a history entry.
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v4",
request_context=request_context,
)

history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 3
# Most recent: v3→v4, previous rr had based_on={} → stored as {"based_on": {}}
assert history[0]["previous_reflect_response"] == {"based_on": {}}
# Second: v2→v3, previous rr had no based_on field → stored as None
assert history[1]["previous_reflect_response"] is None
# Third: v1→v2, no reflect_response on the row yet → stored as None
assert history[2]["previous_reflect_response"] is None

await memory.delete_bank(bank_id, request_context=request_context)

async def test_history_ordered_most_recent_first(self, memory: MemoryEngine, request_context):
"""Test that history is returned most recent first."""
bank_id = f"test-mm-history-order-{uuid.uuid4().hex[:8]}"
Expand Down Expand Up @@ -808,6 +872,48 @@ async def test_history_returns_none_for_missing_model(self, memory: MemoryEngine

await memory.delete_bank(bank_id, request_context=request_context)

async def test_history_capped_to_max_entries(self, memory: MemoryEngine, request_context, monkeypatch):
"""History array is trimmed to the most recent N entries on each write.

Without a cap, sustained updates grow the jsonb array unboundedly and
eventually cross Postgres's 256MB jsonb hard limit, after which any
further UPDATE fails with SQLSTATE 54000 and the row is stuck.
"""
from hindsight_api.config import clear_config_cache

monkeypatch.setenv("HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES", "3")
clear_config_cache()

bank_id = f"test-mm-history-cap-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)

mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="v1",
request_context=request_context,
)

# 5 content updates → 5 history entries appended → trimmed to last 3
for i in range(2, 7):
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content=f"v{i}",
request_context=request_context,
)

history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
# Most recent first ordering (per test_history_ordered_most_recent_first):
# the trimmed window keeps the newest 3 — v5, v4, v3 — and drops v2, v1.
assert len(history) == 3
assert history[0]["previous_content"] == "v5"
assert history[1]["previous_content"] == "v4"
assert history[2]["previous_content"] == "v3"

await memory.delete_bank(bank_id, request_context=request_context)


class TestMentalModelStaleness:
"""Tests for compute_mental_model_is_stale scope semantics.
Expand Down
1 change: 1 addition & 0 deletions hindsight-docs/docs/developer/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
| `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` |
| `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
| `HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES` | Max entries retained in the per-mental-model history jsonb array. Older entries are dropped at write time. Prevents the array from crossing Postgres's hard 256MB jsonb size limit (which would otherwise make further UPDATEs to the row fail with SQLSTATE 54000). Each entry stores only the slim `{based_on}` slice of the prior `reflect_response` (the only field consumed by the control-plane UI's history view) so per-row size stays bounded and HOT updates apply. | `50` |

#### Graph Retrieval Algorithm

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
| `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` |
| `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
| `HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES` | Max entries retained in the per-mental-model history jsonb array. Older entries are dropped at write time. Prevents the array from crossing Postgres's hard 256MB jsonb size limit (which would otherwise make further UPDATEs to the row fail with SQLSTATE 54000). Each entry stores only the slim `{based_on}` slice of the prior `reflect_response` (the only field consumed by the control-plane UI's history view) so per-row size stays bounded and HOT updates apply. | `50` |

#### Graph Retrieval Algorithm

Expand Down
Loading