diff --git a/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py b/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py
new file mode 100644
index 00000000..5ab9bf79
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py
@@ -0,0 +1,41 @@
+"""mental_model_id_to_text
+
+Revision ID: m8h9i0j1k2l3
+Revises: l7g8h9i0j1k2
+Create Date: 2026-01-19 00:00:00.000000
+
+This migration changes the mental_models.id column from VARCHAR(64) to TEXT
+to support longer model IDs (e.g., entity names that exceed 64 characters).
+"""
+
+from collections.abc import Sequence
+
+from alembic import context, op
+
+# revision identifiers, used by Alembic.
+revision: str = "m8h9i0j1k2l3"
+down_revision: str | Sequence[str] | None = "l7g8h9i0j1k2"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_schema_prefix() -> str:
+ """Get schema prefix for table names (required for multi-tenant support)."""
+ schema = context.config.get_main_option("target_schema")
+ return f'"{schema}".' if schema else ""
+
+
+def upgrade() -> None:
+ """Change mental_models.id from VARCHAR(64) to TEXT."""
+ schema = _get_schema_prefix()
+
+ # Alter the id column type from VARCHAR(64) to TEXT
+ op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT")
+
+
+def downgrade() -> None:
+ """Revert mental_models.id from TEXT to VARCHAR(64)."""
+ schema = _get_schema_prefix()
+
+ # Note: This may fail if any id values exceed 64 characters
+ op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE VARCHAR(64)")
diff --git a/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py b/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py
new file mode 100644
index 00000000..1f29d8e8
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py
@@ -0,0 +1,134 @@
+"""learnings_and_pinned_reflections
+
+Revision ID: n9i0j1k2l3m4
+Revises: m8h9i0j1k2l3
+Create Date: 2026-01-21 00:00:00.000000
+
+This migration:
+1. Creates the 'learnings' table for automatic bottom-up consolidation
+2. Creates the 'pinned_reflections' table for user-curated living documents
+3. Adds consolidation tracking columns to the 'banks' table
+"""
+
+from collections.abc import Sequence
+
+from alembic import context, op
+
+# revision identifiers, used by Alembic.
+revision: str = "n9i0j1k2l3m4"
+down_revision: str | Sequence[str] | None = "m8h9i0j1k2l3"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_schema_prefix() -> str:
+ """Get schema prefix for table names (required for multi-tenant support)."""
+ schema = context.config.get_main_option("target_schema")
+ return f'"{schema}".' if schema else ""
+
+
+def upgrade() -> None:
+ """Create learnings and pinned_reflections tables."""
+ schema = _get_schema_prefix()
+
+ # 1. Create learnings table
+ op.execute(f"""
+ CREATE TABLE {schema}learnings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ bank_id VARCHAR(64) NOT NULL,
+ text TEXT NOT NULL,
+ proof_count INT NOT NULL DEFAULT 1,
+ history JSONB DEFAULT '[]'::jsonb,
+ mission_context VARCHAR(64),
+ pre_mission_change BOOLEAN DEFAULT FALSE,
+ embedding vector(384),
+ tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+ )
+ """)
+
+ # Add foreign key constraint
+ op.execute(f"""
+ ALTER TABLE {schema}learnings
+ ADD CONSTRAINT fk_learnings_bank_id
+ FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
+ """)
+
+ # Indexes for learnings
+ op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
+ op.execute(f"""
+ CREATE INDEX idx_learnings_embedding ON {schema}learnings
+ USING hnsw (embedding vector_cosine_ops)
+ """)
+ op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
+
+ # Full-text search for learnings
+ op.execute(f"""
+ ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
+ GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
+ """)
+ op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
+
+ # 2. Create pinned_reflections table
+ op.execute(f"""
+ CREATE TABLE {schema}pinned_reflections (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ bank_id VARCHAR(64) NOT NULL,
+ name VARCHAR(256) NOT NULL,
+ source_query TEXT NOT NULL,
+ content TEXT NOT NULL,
+ embedding vector(384),
+ tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
+ last_refreshed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+ )
+ """)
+
+ # Add foreign key constraint
+ op.execute(f"""
+ ALTER TABLE {schema}pinned_reflections
+ ADD CONSTRAINT fk_pinned_reflections_bank_id
+ FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
+ """)
+
+ # Indexes for pinned_reflections
+ op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
+ op.execute(f"""
+ CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
+ USING hnsw (embedding vector_cosine_ops)
+ """)
+ op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
+
+ # Full-text search for pinned_reflections
+ op.execute(f"""
+ ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
+ GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
+ """)
+ op.execute(f"""
+ CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
+ USING gin(search_vector)
+ """)
+
+ # 3. Add consolidation tracking columns to banks table
+ op.execute(f"""
+ ALTER TABLE {schema}banks
+ ADD COLUMN IF NOT EXISTS last_consolidated_at TIMESTAMP WITH TIME ZONE
+ """)
+ op.execute(f"""
+ ALTER TABLE {schema}banks
+ ADD COLUMN IF NOT EXISTS mission_changed_at TIMESTAMP WITH TIME ZONE
+ """)
+
+
+def downgrade() -> None:
+ """Drop learnings and pinned_reflections tables."""
+ schema = _get_schema_prefix()
+
+ # Drop tables
+ op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
+ op.execute(f"DROP TABLE IF EXISTS {schema}pinned_reflections CASCADE")
+
+ # Remove columns from banks
+ op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS last_consolidated_at")
+ op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission_changed_at")
diff --git a/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py b/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py
new file mode 100644
index 00000000..48545a6c
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py
@@ -0,0 +1,113 @@
+"""migrate_mental_models_data
+
+Revision ID: o0j1k2l3m4n5
+Revises: n9i0j1k2l3m4
+Create Date: 2026-01-21 00:00:00.000000
+
+This migration:
+1. Migrates existing 'pinned' mental models to the new 'pinned_reflections' table
+2. Migrates existing 'learned' mental models to the new 'learnings' table
+3. Deletes non-directive mental models (structural, emergent, pinned, learned)
+4. Drops the mental_model_versions table (no longer used)
+5. Adds a CHECK constraint that only 'directive' subtype is allowed
+"""
+
+from collections.abc import Sequence
+
+from alembic import context, op
+
+# revision identifiers, used by Alembic.
+revision: str = "o0j1k2l3m4n5"
+down_revision: str | Sequence[str] | None = "n9i0j1k2l3m4"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_schema_prefix() -> str:
+ """Get schema prefix for table names (required for multi-tenant support)."""
+ schema = context.config.get_main_option("target_schema")
+ return f'"{schema}".' if schema else ""
+
+
+def upgrade() -> None:
+ """Migrate data and clean up old mental models."""
+ schema = _get_schema_prefix()
+
+ # 1. Migrate 'pinned' mental models to pinned_reflections
+ # For pinned models, the first observation's content becomes the pinned reflection content
+ op.execute(f"""
+ INSERT INTO {schema}pinned_reflections (bank_id, name, source_query, content, tags, created_at)
+ SELECT
+ bank_id,
+ name,
+ description AS source_query,
+ COALESCE(
+ observations->'observations'->0->>'content',
+ description,
+ ''
+ ) AS content,
+ tags,
+ created_at
+ FROM {schema}mental_models
+ WHERE subtype = 'pinned'
+ ON CONFLICT DO NOTHING
+ """)
+
+ # 2. Migrate 'learned' mental models to learnings
+ # Each observation in a learned model becomes a separate learning
+ op.execute(f"""
+ INSERT INTO {schema}learnings (bank_id, text, proof_count, tags, created_at)
+ SELECT
+ mm.bank_id,
+ obs->>'content' AS text,
+ GREATEST(1, COALESCE(jsonb_array_length(obs->'evidence'), 1)) AS proof_count,
+ mm.tags,
+ mm.created_at
+ FROM {schema}mental_models mm,
+ LATERAL jsonb_array_elements(mm.observations->'observations') AS obs
+ WHERE mm.subtype = 'learned'
+ AND obs->>'content' IS NOT NULL
+ AND obs->>'content' != ''
+ ON CONFLICT DO NOTHING
+ """)
+
+ # 3. Delete all non-directive mental models (they've been migrated or are obsolete)
+ op.execute(f"""
+ DELETE FROM {schema}mental_models
+ WHERE subtype != 'directive'
+ """)
+
+ # 4. Drop the mental_model_versions table (no longer used)
+ op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions CASCADE")
+
+ # 5. Drop old constraints and add new one that only allows 'directive'
+ op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
+ op.execute(f"""
+ ALTER TABLE {schema}mental_models
+ ADD CONSTRAINT ck_mental_models_subtype CHECK (subtype = 'directive')
+ """)
+
+
+def downgrade() -> None:
+ """Reverse the migration (data migration is one-way, so this just removes constraints)."""
+ schema = _get_schema_prefix()
+
+ # Remove the directive-only constraint
+ op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
+
+ # Re-create mental_model_versions table
+ op.execute(f"""
+ CREATE TABLE IF NOT EXISTS {schema}mental_model_versions (
+ id SERIAL PRIMARY KEY,
+ bank_id VARCHAR(64) NOT NULL,
+ model_id VARCHAR(128) NOT NULL,
+ version INT NOT NULL,
+ observations JSONB NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+ )
+ """)
+ op.execute(
+ f"CREATE INDEX IF NOT EXISTS idx_mm_versions_lookup ON {schema}mental_model_versions(bank_id, model_id, version DESC)"
+ )
+
+ # Note: Data migration cannot be reversed - pinned_reflections and learnings data remains
diff --git a/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py b/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py
new file mode 100644
index 00000000..8d9002e4
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py
@@ -0,0 +1,194 @@
+"""new_knowledge_architecture
+
+Revision ID: p1k2l3m4n5o6
+Revises: o0j1k2l3m4n5
+Create Date: 2026-01-21 00:00:00.000000
+
+This migration implements the new knowledge architecture:
+1. Drops the 'learnings' table (mental models are now in memory_units)
+2. Renames 'pinned_reflections' to 'reflections'
+3. Drops the 'mental_models' table completely
+4. Creates 'directives' table for hard rules
+5. Adds mental model support columns to 'memory_units' (proof_count, source_memory_ids, history)
+
+The new architecture:
+- Directives: Hard rules in their own table
+- Mental Models: Stored in memory_units with fact_type='mental_model'
+- Reflections: User-curated documents (renamed from pinned_reflections)
+"""
+
+from collections.abc import Sequence
+
+from alembic import context, op
+
+# revision identifiers, used by Alembic.
+revision: str = "p1k2l3m4n5o6"
+down_revision: str | Sequence[str] | None = "o0j1k2l3m4n5"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_schema_prefix() -> str:
+ """Get schema prefix for table names (required for multi-tenant support)."""
+ schema = context.config.get_main_option("target_schema")
+ return f'"{schema}".' if schema else ""
+
+
+def upgrade() -> None:
+ """Implement new knowledge architecture."""
+ schema = _get_schema_prefix()
+
+ # 1. Drop the learnings table (mental models will be in memory_units)
+ op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
+
+ # 2. Rename pinned_reflections to reflections
+ op.execute(f"ALTER TABLE IF EXISTS {schema}pinned_reflections RENAME TO reflections")
+
+ # Rename indexes for reflections
+ op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_bank_id RENAME TO idx_reflections_bank_id")
+ op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_embedding RENAME TO idx_reflections_embedding")
+ op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_tags RENAME TO idx_reflections_tags")
+ op.execute(
+ f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_text_search RENAME TO idx_reflections_text_search"
+ )
+
+ # Rename foreign key constraint
+ op.execute(f"""
+ ALTER TABLE {schema}reflections
+ DROP CONSTRAINT IF EXISTS fk_pinned_reflections_bank_id
+ """)
+ op.execute(f"""
+ ALTER TABLE {schema}reflections
+ ADD CONSTRAINT fk_reflections_bank_id
+ FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
+ """)
+
+ # 3. Drop the mental_models table completely
+ op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
+
+ # 4. Create directives table
+ op.execute(f"""
+ CREATE TABLE {schema}directives (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ bank_id VARCHAR(64) NOT NULL,
+ name VARCHAR(256) NOT NULL,
+ content TEXT NOT NULL,
+ priority INT NOT NULL DEFAULT 0,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+ )
+ """)
+
+ # Add foreign key and indexes for directives
+ op.execute(f"""
+ ALTER TABLE {schema}directives
+ ADD CONSTRAINT fk_directives_bank_id
+ FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
+ """)
+ op.execute(f"CREATE INDEX idx_directives_bank_id ON {schema}directives(bank_id)")
+ op.execute(f"CREATE INDEX idx_directives_bank_active ON {schema}directives(bank_id, is_active)")
+ op.execute(f"CREATE INDEX idx_directives_tags ON {schema}directives USING GIN(tags)")
+
+ # 5. Add mental model support columns to memory_units
+ # proof_count: Number of memories that support this mental model
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD COLUMN IF NOT EXISTS proof_count INT DEFAULT 1
+ """)
+
+ # source_memory_ids: Array of memory IDs that consolidated into this mental model
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] DEFAULT ARRAY[]::UUID[]
+ """)
+
+ # history: JSONB array tracking changes to mental models
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb
+ """)
+
+ # Add index for finding mental models
+ op.execute(f"""
+ CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
+ ON {schema}memory_units(bank_id, fact_type)
+ WHERE fact_type = 'mental_model'
+ """)
+
+ # 6. Update fact_type check constraint to include 'mental_model'
+ op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD CONSTRAINT memory_units_fact_type_check
+ CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
+ """)
+
+
+def downgrade() -> None:
+ """Reverse the migration."""
+ schema = _get_schema_prefix()
+
+ # Restore original fact_type check constraint (without 'mental_model')
+ op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD CONSTRAINT memory_units_fact_type_check
+ CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
+ """)
+
+ # Drop mental model columns from memory_units
+ op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS proof_count")
+ op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS source_memory_ids")
+ op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
+ op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
+
+ # Drop directives table
+ op.execute(f"DROP TABLE IF EXISTS {schema}directives CASCADE")
+
+ # Rename reflections back to pinned_reflections
+ op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO pinned_reflections")
+
+ # Restore indexes
+ op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_pinned_reflections_bank_id")
+ op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_pinned_reflections_embedding")
+ op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_pinned_reflections_tags")
+ op.execute(
+ f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_pinned_reflections_text_search"
+ )
+
+ # Restore foreign key
+ op.execute(f"""
+ ALTER TABLE {schema}pinned_reflections
+ DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
+ """)
+ op.execute(f"""
+ ALTER TABLE {schema}pinned_reflections
+ ADD CONSTRAINT fk_pinned_reflections_bank_id
+ FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
+ """)
+
+ # Re-create learnings table
+ op.execute(f"""
+ CREATE TABLE IF NOT EXISTS {schema}learnings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ bank_id VARCHAR(64) NOT NULL,
+ text TEXT NOT NULL,
+ proof_count INT NOT NULL DEFAULT 1,
+ history JSONB DEFAULT '[]'::jsonb,
+ mission_context VARCHAR(64),
+ pre_mission_change BOOLEAN DEFAULT FALSE,
+ embedding vector(384),
+ tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+ )
+ """)
+ op.execute(f"""
+ ALTER TABLE {schema}learnings
+ ADD CONSTRAINT fk_learnings_bank_id
+ FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
+ """)
+
+ # Note: mental_models table recreation is complex and would need separate handling
diff --git a/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py b/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py
new file mode 100644
index 00000000..9d3aa36e
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py
@@ -0,0 +1,50 @@
+"""fix_mental_model_fact_type
+
+Revision ID: q2l3m4n5o6p7
+Revises: p1k2l3m4n5o6
+Create Date: 2026-01-21 13:30:00.000000
+
+Fix the fact_type check constraint to include 'mental_model'.
+This is a fix for p1k2l3m4n5o6 which should have included this change.
+"""
+
+from collections.abc import Sequence
+
+from alembic import context, op
+
+# revision identifiers, used by Alembic.
+revision: str = "q2l3m4n5o6p7"
+down_revision: str | Sequence[str] | None = "p1k2l3m4n5o6"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_schema_prefix() -> str:
+ """Get schema prefix for table names (required for multi-tenant support)."""
+ schema = context.config.get_main_option("target_schema")
+ return f'"{schema}".' if schema else ""
+
+
+def upgrade() -> None:
+ """Add 'mental_model' to the fact_type check constraint."""
+ schema = _get_schema_prefix()
+
+ # Drop the old constraint and add the new one with mental_model included
+ op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD CONSTRAINT memory_units_fact_type_check
+ CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
+ """)
+
+
+def downgrade() -> None:
+ """Remove 'mental_model' from the fact_type check constraint."""
+ schema = _get_schema_prefix()
+
+ op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
+ op.execute(f"""
+ ALTER TABLE {schema}memory_units
+ ADD CONSTRAINT memory_units_fact_type_check
+ CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
+ """)
diff --git a/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py b/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py
new file mode 100644
index 00000000..4c5a2cf6
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py
@@ -0,0 +1,47 @@
+"""Add reflect_response JSONB column to reflections
+
+Revision ID: r3m4n5o6p7q8
+Revises: q2l3m4n5o6p7
+Create Date: 2026-01-21
+
+This migration adds a reflect_response JSONB column to store the full
+reflect API response payload, including based_on facts and trace data.
+
+Note: Table was renamed from pinned_reflections to reflections in p1k2l3m4n5o6.
+"""
+
+from collections.abc import Sequence
+
+from alembic import context, op
+
+revision: str = "r3m4n5o6p7q8"
+down_revision: str | Sequence[str] | None = "q2l3m4n5o6p7"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_schema_prefix() -> str:
+ """Get schema prefix for table names (required for multi-tenant support)."""
+ schema = context.config.get_main_option("target_schema")
+ return f'"{schema}".' if schema else ""
+
+
+def upgrade() -> None:
+ """Add reflect_response JSONB column to reflections."""
+ schema = _get_schema_prefix()
+
+ # Add reflect_response column to store the full reflect API response
+ op.execute(f"""
+ ALTER TABLE {schema}reflections
+ ADD COLUMN IF NOT EXISTS reflect_response JSONB
+ """)
+
+
+def downgrade() -> None:
+ """Remove reflect_response column from reflections."""
+ schema = _get_schema_prefix()
+
+ op.execute(f"""
+ ALTER TABLE {schema}reflections
+ DROP COLUMN IF EXISTS reflect_response
+ """)
diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py
index e7c3ab6c..42dde613 100644
--- a/hindsight-api/hindsight_api/api/http.py
+++ b/hindsight-api/hindsight_api/api/http.py
@@ -92,7 +92,7 @@ class RecallRequest(BaseModel):
query: str
types: list[str] | None = Field(
default=None,
- description="List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. "
+ description="List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. "
"Note: 'opinion' is accepted but ignored (opinions are excluded from recall).",
)
budget: Budget = Budget.MID
@@ -570,9 +570,6 @@ class ReflectBasedOn(BaseModel):
"""Evidence the response is based on: memories and mental models."""
memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response")
- mental_models: list[ReflectMentalModel] = Field(
- default_factory=list, description="Mental models accessed during reflection"
- )
class ReflectTrace(BaseModel):
@@ -586,14 +583,6 @@ class ReflectTrace(BaseModel):
)
-class CreatedMentalModel(BaseModel):
- """A mental model created during reflection."""
-
- id: str = Field(description="Mental model ID")
- name: str = Field(description="Human-readable name")
- description: str = Field(description="What this model tracks")
-
-
class ReflectResponse(BaseModel):
"""Response model for think endpoint."""
@@ -625,9 +614,6 @@ class ReflectResponse(BaseModel):
"tool_calls": [{"tool": "recall", "input": {"query": "AI"}, "duration_ms": 150}],
"llm_calls": [{"scope": "agent_1", "duration_ms": 1200}],
},
- "mental_models_created": [
- {"id": "mm-new-1", "name": "AI Strategy", "description": "Track AI-related decisions and plans"}
- ],
}
}
)
@@ -649,10 +635,6 @@ class ReflectResponse(BaseModel):
default=None,
description="Execution trace of tool and LLM calls. Only present when include.tool_calls is set.",
)
- mental_models_created: list[CreatedMentalModel] = Field(
- default_factory=list,
- description="Mental models created during this reflection (via the learn tool).",
- )
class BanksResponse(BaseModel):
@@ -1032,6 +1014,9 @@ class BankStatsResponse(BaseModel):
"links_breakdown": {"fact": {"temporal": 100, "semantic": 60, "entity": 40}},
"pending_operations": 2,
"failed_operations": 0,
+ "last_consolidated_at": "2024-01-15T10:30:00Z",
+ "pending_consolidation": 0,
+ "total_mental_models": 45,
}
}
)
@@ -1046,6 +1031,10 @@ class BankStatsResponse(BaseModel):
links_breakdown: dict[str, dict[str, int]]
pending_operations: int
failed_operations: int
+ # Consolidation stats
+ last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)")
+ pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into mental models")
+ total_mental_models: int = Field(default=0, description="Total number of mental models")
# Mental Model models
@@ -1060,205 +1049,117 @@ class ObservationEvidenceResponse(BaseModel):
timestamp: str = Field(description="When the source memory was created (ISO format)")
-class MentalModelObservationResponse(BaseModel):
- """An observation within a mental model with its supporting evidence."""
-
- title: str = Field(description="Short summary title for the observation")
- content: str = Field(description="The observation content - detailed explanation")
- evidence: list[ObservationEvidenceResponse] = Field(
- default_factory=list, description="Supporting evidence with quotes"
- )
- created_at: str = Field(description="When this observation was first created (ISO format)")
- trend: str = Field(description="Computed trend: stable, strengthening, weakening, new, stale")
- evidence_count: int = Field(description="Number of evidence items supporting this observation")
- evidence_span: dict = Field(description="Time span of evidence: {from: iso_date, to: iso_date}")
+# =========================================================================
+# Directive Models
+# =========================================================================
-class MentalModelFreshnessResponse(BaseModel):
- """Freshness information for a mental model."""
-
- is_up_to_date: bool = Field(description="Whether the model has been refreshed since the last memory was added")
- last_refresh_at: str | None = Field(description="When the model was last refreshed (ISO format)")
- memories_since_refresh: int = Field(description="Number of memories added since last refresh")
- reasons: list[str] = Field(
- default_factory=list,
- description="Reasons why the model needs refresh (empty if up to date). "
- "Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed",
- )
-
-
-class MentalModelResponse(BaseModel):
- """Response model for a mental model."""
-
- model_config = ConfigDict(
- json_schema_extra={
- "example": {
- "id": "team-structure",
- "bank_id": "test-bank",
- "subtype": "structural",
- "name": "Team Structure",
- "description": "Who's on the team and their roles",
- "observations": [
- {
- "title": "Prefers async communication",
- "content": "The team prefers async communication over synchronous meetings",
- "evidence": [
- {
- "memory_id": "uuid1",
- "quote": "I prefer Slack over meetings",
- "relevance": "Shows async preference",
- "timestamp": "2024-01-10T08:00:00Z",
- }
- ],
- "created_at": "2024-01-15T10:30:00Z",
- "trend": "stable",
- "evidence_count": 1,
- "evidence_span": {"from": "2024-01-10T08:00:00Z", "to": "2024-01-10T08:00:00Z"},
- }
- ],
- "version": 1,
- "entity_id": None,
- "links": [],
- "tags": ["project-x"],
- "last_updated": "2024-01-15T10:30:00Z",
- "last_refresh_at": "2024-01-15T10:30:00Z",
- "freshness": {
- "is_up_to_date": True,
- "last_refresh_at": "2024-01-15T10:30:00Z",
- "memories_since_refresh": 0,
- "reasons": [],
- },
- "created_at": "2024-01-10T08:00:00Z",
- }
- }
- )
+class DirectiveResponse(BaseModel):
+ """Response model for a directive."""
id: str
bank_id: str
- subtype: str
name: str
- description: str
- observations: list[MentalModelObservationResponse] = Field(
- default_factory=list, description="Structured observations with per-observation fact attribution"
- )
- version: int = Field(default=0, description="Version number of the mental model observations")
- entity_id: str | None = None
- links: list[str] = []
- tags: list[str] = []
- last_updated: str | None = None
- last_refresh_at: str | None = Field(default=None, description="When observations were last refreshed (ISO format)")
- freshness: MentalModelFreshnessResponse | None = Field(
- default=None, description="Freshness info (null for directive subtypes which don't need refresh)"
- )
- created_at: str
+ content: str
+ priority: int = 0
+ is_active: bool = True
+ tags: list[str] = Field(default_factory=list)
+ created_at: str | None = None
+ updated_at: str | None = None
-class MentalModelListResponse(BaseModel):
- """Response model for listing mental models."""
+class DirectiveListResponse(BaseModel):
+ """Response model for listing directives."""
- items: list[MentalModelResponse]
+ items: list[DirectiveResponse]
-def _observation_to_response(obs: Observation) -> MentalModelObservationResponse:
- """Convert internal Observation model to API response model."""
- return MentalModelObservationResponse(
- title=obs.title,
- content=obs.content,
- evidence=[
- ObservationEvidenceResponse(
- memory_id=ev.memory_id,
- quote=ev.quote,
- relevance=ev.relevance,
- timestamp=ev.timestamp.isoformat(),
- )
- for ev in obs.evidence
- ],
- created_at=obs.created_at.isoformat(),
- trend=obs.trend.value,
- evidence_count=obs.evidence_count,
- evidence_span=obs.evidence_span,
- )
+class CreateDirectiveRequest(BaseModel):
+ """Request model for creating a directive."""
+
+ name: str = Field(description="Human-readable name for the directive")
+ content: str = Field(description="The directive text to inject into prompts")
+ priority: int = Field(default=0, description="Higher priority directives are injected first")
+ is_active: bool = Field(default=True, description="Whether this directive is active")
+ tags: list[str] = Field(default_factory=list, description="Tags for filtering")
-def _prepare_mental_model_response(model: dict[str, Any]) -> MentalModelResponse:
- """Convert internal mental model dict to API response model.
+class UpdateDirectiveRequest(BaseModel):
+ """Request model for updating a directive."""
- Handles conversion of Observation models to MentalModelObservationResponse.
- """
- observations = model.get("observations", [])
- converted_observations = [
- _observation_to_response(obs) if isinstance(obs, Observation) else obs for obs in observations
- ]
- return MentalModelResponse(**{**model, "observations": converted_observations})
+ name: str | None = Field(default=None, description="New name")
+ content: str | None = Field(default=None, description="New content")
+ priority: int | None = Field(default=None, description="New priority")
+ is_active: bool | None = Field(default=None, description="New active status")
+ tags: list[str] | None = Field(default=None, description="New tags")
-class RefreshMentalModelsRequest(BaseModel):
- """Request model for refresh mental models endpoint."""
+# =========================================================================
+# Reflections Models
+# =========================================================================
- model_config = ConfigDict(json_schema_extra={"example": {"tags": ["project-x"], "subtype": "structural"}})
- tags: list[str] | None = Field(default=None, description="Tags to apply to newly created mental models")
- subtype: Literal["structural", "emergent", "pinned", "learned"] | None = Field(
+class ReflectionResponse(BaseModel):
+ """Response model for a reflection."""
+
+ id: str
+ bank_id: str
+ name: str
+ source_query: str
+ content: str
+ tags: list[str] = Field(default_factory=list)
+ last_refreshed_at: str | None = None
+ created_at: str | None = None
+ reflect_response: dict | None = Field(
default=None,
- description="Only refresh models of this subtype. If not specified, refreshes all subtypes.",
+ description="Full reflect API response payload including based_on facts and mental_models",
)
-class ObservationInput(BaseModel):
- """Input model for a single observation."""
+class ReflectionListResponse(BaseModel):
+ """Response model for listing reflections."""
- title: str = Field(description="Short title/header for the observation")
- content: str = Field(description="Content of the observation")
+ items: list[ReflectionResponse]
-class CreateMentalModelRequest(BaseModel):
- """Request model for creating a mental model."""
-
- model_config = ConfigDict(
- json_schema_extra={
- "examples": [
- {
- "name": "Product Roadmap",
- "description": "Key product priorities and upcoming features",
- "tags": ["project-x"],
- },
- {
- "name": "Meeting Rules",
- "description": "Rules about scheduling meetings",
- "subtype": "directive",
- "observations": [{"title": "Morning meetings", "content": "Never schedule meetings before 10am"}],
- },
- ]
- }
- )
-
- name: str = Field(description="Human-readable name for the mental model")
- description: str = Field(description="One-liner description for quick scanning")
- subtype: str = Field(
- default="pinned",
- description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)",
- )
- observations: list[ObservationInput] | None = Field(
- default=None,
- description="For directives only: list of user-provided observations. Required when subtype='directive'.",
- )
- tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
-
-
-class UpdateMentalModelRequest(BaseModel):
- """Request model for updating a mental model."""
+class CreateReflectionRequest(BaseModel):
+ """Request model for creating a reflection."""
model_config = ConfigDict(
json_schema_extra={
"example": {
- "name": "Updated Name",
- "description": "Updated description with new rules",
+ "name": "Team Communication Preferences",
+ "source_query": "How does the team prefer to communicate?",
+ "tags": ["team"],
+ "max_tokens": 2048,
}
}
)
- name: str | None = Field(default=None, description="New name for the mental model")
- description: str | None = Field(default=None, description="New description/rule text")
+ name: str = Field(description="Human-readable name for the reflection")
+ source_query: str = Field(description="The query to run to generate content")
+ tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
+ max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content")
+
+
+class CreateReflectionResponse(BaseModel):
+ """Response model for reflection creation."""
+
+ operation_id: str = Field(description="Operation ID to track progress")
+
+
+class UpdateReflectionRequest(BaseModel):
+ """Request model for updating a reflection."""
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "name": "Updated Team Communication Preferences",
+ }
+ }
+ )
+
+ name: str | None = Field(default=None, description="New name for the reflection")
class OperationResponse(BaseModel):
@@ -1287,6 +1188,16 @@ class OperationResponse(BaseModel):
error_message: str | None
+class ConsolidationResponse(BaseModel):
+ """Response model for consolidation trigger endpoint."""
+
+ status: str = Field(description="Status of the consolidation (completed or queued)")
+ processed: int = Field(description="Number of memories processed")
+ created: int = Field(description="Number of mental models created")
+ updated: int = Field(description="Number of mental models updated")
+ message: str = Field(description="Human-readable summary")
+
+
class OperationsListResponse(BaseModel):
"""Response model for list operations endpoint."""
@@ -1295,6 +1206,8 @@ class OperationsListResponse(BaseModel):
"example": {
"bank_id": "user123",
"total": 150,
+ "limit": 20,
+ "offset": 0,
"operations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
@@ -1310,6 +1223,8 @@ class OperationsListResponse(BaseModel):
bank_id: str
total: int
+ limit: int
+ offset: int
operations: list[OperationResponse]
@@ -1373,6 +1288,34 @@ class AsyncOperationSubmitResponse(BaseModel):
status: str
+class FeaturesInfo(BaseModel):
+ """Feature flags indicating which capabilities are enabled."""
+
+ mental_models: bool = Field(description="Whether mental models (auto-consolidation) are enabled")
+ mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
+ worker: bool = Field(description="Whether the background worker is enabled")
+
+
+class VersionResponse(BaseModel):
+ """Response model for the version/info endpoint."""
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "api_version": "1.0.0",
+ "features": {
+ "mental_models": False,
+ "mcp": True,
+ "worker": True,
+ },
+ }
+ }
+ )
+
+ api_version: str = Field(description="API version string")
+ features: FeaturesInfo = Field(description="Enabled feature flags")
+
+
def create_app(
memory: MemoryEngine,
initialize_memory: bool = True,
@@ -1586,6 +1529,34 @@ def _register_routes(app: FastAPI):
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
+ @app.get(
+ "/version",
+ response_model=VersionResponse,
+ summary="Get API version and feature flags",
+ description="Returns API version information and enabled feature flags. "
+ "Use this to check which capabilities are available in this deployment.",
+ tags=["Monitoring"],
+ operation_id="get_version",
+ )
+ async def version_endpoint() -> VersionResponse:
+ """
+ Get API version and enabled features.
+
+ Returns version info and feature flags that can be used by clients
+ to determine which capabilities are available.
+ """
+ from hindsight_api.config import get_config
+
+ config = get_config()
+ return VersionResponse(
+ api_version="1.0.0",
+ features=FeaturesInfo(
+ mental_models=config.enable_mental_models,
+ mcp=config.mcp_enabled,
+ worker=config.worker_enabled,
+ ),
+ )
+
@app.get(
"/metrics",
summary="Prometheus metrics endpoint",
@@ -1821,7 +1792,10 @@ def _register_routes(app: FastAPI):
)
response = RecallResponse(
- results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response
+ results=recall_results,
+ trace=core_result.trace,
+ entities=entities_response,
+ chunks=chunks_response,
)
handler_duration = time.time() - handler_start
@@ -1907,16 +1881,7 @@ def _register_routes(app: FastAPI):
occurred_end=fact.occurred_end,
)
)
- mental_models = [
- ReflectMentalModel(
- id=mm.id,
- name=mm.name,
- type=mm.type,
- subtype=mm.subtype,
- )
- for mm in core_result.mental_models
- ]
- based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models)
+ based_on_result = ReflectBasedOn(memories=memories)
# Build trace (tool_calls + llm_calls + mental_models) if tool_calls is requested
trace_result: ReflectTrace | None = None
@@ -1935,42 +1900,39 @@ def _register_routes(app: FastAPI):
llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace]
# Build map of directive observations by id
directive_observations = {d.id: d.rules for d in core_result.directives_applied}
- # Include all mental models (including directives with subtype='directive')
- trace_mental_models = [
- ReflectMentalModel(
- id=mm.id,
- name=mm.name,
- type=mm.type,
- subtype=mm.subtype,
- observations=directive_observations.get(mm.id) if mm.subtype == "directive" else None,
- )
- for mm in core_result.mental_models
- ]
+ # Build mental models from tool trace (get_mental_model outputs)
+ trace_mental_models: list[ReflectMentalModel] = []
+ seen_model_ids: set[str] = set()
+ for tc in core_result.tool_trace:
+ if tc.tool == "get_mental_model" and tc.output.get("found") and "model" in tc.output:
+ model = tc.output["model"]
+ model_id = model.get("id")
+ if model_id and model_id not in seen_model_ids:
+ seen_model_ids.add(model_id)
+ model_subtype = model.get("subtype", "structural")
+ trace_mental_models.append(
+ ReflectMentalModel(
+ id=model_id,
+ name=model.get("name", ""),
+ type=model.get("type", "concept"),
+ subtype=model_subtype,
+ observations=directive_observations.get(model_id)
+ if model_subtype == "directive"
+ else None,
+ )
+ )
trace_result = ReflectTrace(
tool_calls=tool_calls,
llm_calls=llm_calls,
mental_models=trace_mental_models,
)
- # Build mental_models_created from tool trace (learn tool outputs)
- created_models: list[CreatedMentalModel] = []
- for tc in core_result.tool_trace:
- if tc.tool == "learn" and isinstance(tc.output, dict) and tc.output.get("status") == "created":
- created_models.append(
- CreatedMentalModel(
- id=tc.output.get("model_id", ""),
- name=tc.input.get("name", ""),
- description=tc.input.get("description", ""),
- )
- )
-
return ReflectResponse(
text=core_result.text,
based_on=based_on_result,
structured_output=core_result.structured_output,
usage=core_result.usage,
trace=trace_result,
- mental_models_created=created_models,
)
except OperationValidationError as e:
@@ -2096,6 +2058,54 @@ def _register_routes(app: FastAPI):
)
total_documents = doc_count_result["count"] if doc_count_result else 0
+ # Get consolidation stats
+ bank_row = await conn.fetchrow(
+ f"""
+ SELECT last_consolidated_at
+ FROM {fq_table("banks")}
+ WHERE bank_id = $1
+ """,
+ bank_id,
+ )
+ last_consolidated_at = bank_row["last_consolidated_at"] if bank_row else None
+
+ # Count memories pending consolidation (created after last_consolidated_at)
+ if last_consolidated_at:
+ pending_consolidation_result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1
+ AND created_at > $2
+ AND fact_type IN ('experience', 'world')
+ """,
+ bank_id,
+ last_consolidated_at,
+ )
+ else:
+ # If never consolidated, count all experience/world memories
+ pending_consolidation_result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1
+ AND fact_type IN ('experience', 'world')
+ """,
+ bank_id,
+ )
+ pending_consolidation = pending_consolidation_result["count"] if pending_consolidation_result else 0
+
+ # Count total mental models
+ mental_model_count_result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ total_mental_models = mental_model_count_result["count"] if mental_model_count_result else 0
+
# Format results
nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats}
links_by_type = {row["link_type"]: row["count"] for row in link_stats}
@@ -2125,6 +2135,9 @@ def _register_routes(app: FastAPI):
links_breakdown=links_breakdown,
pending_operations=pending_operations,
failed_operations=failed_operations,
+ last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None),
+ pending_consolidation=pending_consolidation,
+ total_mental_models=total_mental_models,
)
except (AuthenticationError, HTTPException):
@@ -2223,128 +2236,111 @@ def _register_routes(app: FastAPI):
entity_id: str,
request_context: RequestContext = Depends(get_request_context),
):
- """Regenerate observations for an entity. DEPRECATED: Use mental models instead."""
+ """Regenerate observations for an entity. DEPRECATED."""
raise HTTPException(
status_code=410,
- detail="This endpoint is deprecated. Entity observations have been replaced by mental models. "
- "Use the /mental-models endpoints instead.",
+ detail="This endpoint is deprecated. Entity observations are no longer supported.",
)
# =========================================================================
- # Mental Models endpoints
+ # =========================================================================
+ # REFLECTIONS ENDPOINTS
# =========================================================================
@app.get(
- "/v1/default/banks/{bank_id}/mental-models",
- response_model=MentalModelListResponse,
- summary="List mental models",
- description="List all mental models for a bank, optionally filtered by subtype or tags.",
- operation_id="list_mental_models",
- tags=["Mental Models"],
+ "/v1/default/banks/{bank_id}/reflections",
+ response_model=ReflectionListResponse,
+ summary="List reflections",
+ description="List user-curated living documents that stay current.",
+ operation_id="list_reflections",
+ tags=["Reflections"],
)
- async def api_list_mental_models(
+ async def api_list_reflections(
bank_id: str,
- subtype: str | None = Query(None, description="Filter by subtype: structural, emergent, or pinned"),
- tags_filter: list[str] | None = Query(
- None, alias="tags", description="Filter by tags (includes untagged models)"
- ),
- tags_match: Literal["any", "all", "exact"] = Query(
- "any", description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'"
- ),
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
+ limit: int = Query(100, ge=1, le=1000),
+ offset: int = Query(0, ge=0),
request_context: RequestContext = Depends(get_request_context),
):
- """List mental models for a bank."""
+ """List reflections for a bank."""
try:
- models = await app.state.memory.list_mental_models(
+ reflections = await app.state.memory.list_reflections(
bank_id=bank_id,
- subtype=subtype,
tags=tags_filter,
tags_match=tags_match,
+ limit=limit,
+ offset=offset,
request_context=request_context,
)
-
- # Add freshness to each model (skip for directives)
- # Get data needed for freshness computation (once for all models)
- from hindsight_api.engine.reflect.mental_model_reflect import (
- BankProfile,
- DirectiveMentalModel,
- check_needs_refresh,
- )
-
- total_memories = await app.state.memory._count_memories_since(bank_id, None)
- bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Convert to typed models at the boundary
- bank_profile = BankProfile.model_validate(bank_profile_dict)
- directives = [DirectiveMentalModel.model_validate(m) for m in models if m.get("subtype") == "directive"]
-
- for model in models:
- if model.get("subtype") != "directive":
- last_refresh_at = model.get("last_refresh_at")
- memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at)
-
- # Use check_needs_refresh to get reasons
- stored_refresh_state = model.get("refresh_state")
- refresh_check = check_needs_refresh(
- stored_state=stored_refresh_state,
- current_memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- model["freshness"] = {
- "is_up_to_date": not refresh_check.needs_refresh,
- "last_refresh_at": last_refresh_at,
- "memories_since_refresh": memories_since,
- "reasons": refresh_check.reasons,
- }
- else:
- model["freshness"] = None
-
- return MentalModelListResponse(items=[_prepare_mental_model_response(m) for m in models])
+ return ReflectionListResponse(items=[ReflectionResponse(**r) for r in reflections])
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models: {error_detail}")
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.get(
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
+ response_model=ReflectionResponse,
+ summary="Get reflection",
+ description="Get a specific reflection by ID.",
+ operation_id="get_reflection",
+ tags=["Reflections"],
+ )
+ async def api_get_reflection(
+ bank_id: str,
+ reflection_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Get a reflection by ID."""
+ try:
+ reflection = await app.state.memory.get_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ request_context=request_context,
+ )
+ if reflection is None:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return ReflectionResponse(**reflection)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
- "/v1/default/banks/{bank_id}/mental-models",
- response_model=MentalModelResponse,
- summary="Create mental model",
- description=(
- "Create a mental model. Supports two subtypes:\n"
- "- 'pinned' (default): User-defined topic, observations are LLM-generated on refresh\n"
- "- 'directive': User-defined hard rules, observations are provided at creation and never regenerated"
- ),
- operation_id="create_mental_model",
- tags=["Mental Models"],
+ "/v1/default/banks/{bank_id}/reflections",
+ response_model=CreateReflectionResponse,
+ summary="Create reflection",
+ description="Create a reflection by running reflect with the source query in the background. "
+ "Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. "
+ "Use the operations endpoint to check completion status.",
+ operation_id="create_reflection",
+ tags=["Reflections"],
)
- async def api_create_mental_model(
+ async def api_create_reflection(
bank_id: str,
- body: CreateMentalModelRequest,
+ body: CreateReflectionRequest,
request_context: RequestContext = Depends(get_request_context),
):
- """Create a mental model (pinned or directive)."""
+ """Create a reflection (async - returns operation_id)."""
try:
- # Convert observations to list of dicts if provided
- observations_list = None
- if body.observations:
- observations_list = [{"title": obs.title, "content": obs.content} for obs in body.observations]
-
- model = await app.state.memory.create_mental_model(
+ result = await app.state.memory.submit_async_create_reflection(
bank_id=bank_id,
name=body.name,
- description=body.description,
- subtype=body.subtype,
- observations=observations_list,
- tags=body.tags,
+ source_query=body.source_query,
+ tags=body.tags if body.tags else None,
+ max_tokens=body.max_tokens,
request_context=request_context,
)
- return _prepare_mental_model_response(model)
+ return CreateReflectionResponse(operation_id=result["operation_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
@@ -2353,286 +2349,32 @@ def _register_routes(app: FastAPI):
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.get(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}",
- response_model=MentalModelResponse,
- summary="Get mental model",
- description="Get a specific mental model by ID.",
- operation_id="get_mental_model",
- tags=["Mental Models"],
- )
- async def api_get_mental_model(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Get a mental model by ID."""
- try:
- model = await app.state.memory.get_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- if model is None:
- raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
-
- # Compute freshness for non-directive models
- if model.get("subtype") != "directive":
- from hindsight_api.engine.reflect.mental_model_reflect import (
- BankProfile,
- DirectiveMentalModel,
- check_needs_refresh,
- )
-
- last_refresh_at = model.get("last_refresh_at")
- total_memories = await app.state.memory._count_memories_since(bank_id, None)
- memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at)
- bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
- directives_dicts = await app.state.memory.list_mental_models(
- bank_id, subtype="directive", request_context=request_context
- )
-
- # Convert to typed models at the boundary
- bank_profile = BankProfile.model_validate(bank_profile_dict)
- directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts]
-
- # Use check_needs_refresh to get reasons
- stored_refresh_state = model.get("refresh_state")
- refresh_check = check_needs_refresh(
- stored_state=stored_refresh_state,
- current_memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- model["freshness"] = {
- "is_up_to_date": not refresh_check.needs_refresh,
- "last_refresh_at": last_refresh_at,
- "memories_since_refresh": memories_since,
- "reasons": refresh_check.reasons,
- }
- else:
- # Directives don't need freshness - they're static
- model["freshness"] = None
-
- return _prepare_mental_model_response(model)
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/reflections: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
- "/v1/default/banks/{bank_id}/mental-models/refresh",
- response_model=AsyncOperationSubmitResponse,
- summary="Refresh mental models (async)",
- description="Submit a background job to refresh mental models for a bank. "
- "By default refreshes all subtypes. Optionally specify 'subtype' to only refresh "
- "'structural' (from mission) or 'emergent' (from entities) models. "
- "Optionally pass tags to apply to newly created models. "
- "Use GET /banks/{bank_id}/operations to check progress.",
- operation_id="refresh_mental_models",
- tags=["Mental Models"],
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh",
+ response_model=ReflectionResponse,
+ summary="Refresh reflection",
+ description="Re-run the source query through reflect and update the content.",
+ operation_id="refresh_reflection",
+ tags=["Reflections"],
)
- async def api_refresh_mental_models(
+ async def api_refresh_reflection(
bank_id: str,
- body: RefreshMentalModelsRequest | None = None,
+ reflection_id: str,
request_context: RequestContext = Depends(get_request_context),
):
- """Submit a background job to refresh mental models for a bank.
-
- Requires a mission to be set for the bank first.
- Optionally pass tags to apply to newly created mental models.
- Optionally specify a subtype to only refresh models of that type.
- """
+ """Refresh a reflection by re-running its source query."""
try:
- result = await app.state.memory.refresh_mental_models(
+ reflection = await app.state.memory.refresh_reflection(
bank_id=bank_id,
- tags=body.tags if body else None,
- subtype=body.subtype if body else None,
+ reflection_id=reflection_id,
request_context=request_context,
)
- return AsyncOperationSubmitResponse(**result)
- except ValueError as e:
- # Mission not set or other validation error
- raise HTTPException(status_code=400, detail=str(e))
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/refresh: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.delete(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}",
- response_model=DeleteResponse,
- summary="Delete mental model",
- description="Delete a mental model.",
- operation_id="delete_mental_model",
- tags=["Mental Models"],
- )
- async def api_delete_mental_model(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Delete a mental model."""
- try:
- deleted = await app.state.memory.delete_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- if not deleted:
- raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
- return DeleteResponse(success=True, deleted_count=1)
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.patch(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}",
- response_model=MentalModelResponse,
- summary="Update mental model",
- description="Update a mental model's name and/or description. Useful for editing directives.",
- operation_id="update_mental_model",
- tags=["Mental Models"],
- )
- async def api_update_mental_model(
- bank_id: str,
- model_id: str,
- body: UpdateMentalModelRequest,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Update a mental model's name and/or description."""
- try:
- if body.name is None and body.description is None:
- raise HTTPException(status_code=400, detail="At least one of 'name' or 'description' must be provided")
-
- updated = await app.state.memory.update_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- name=body.name,
- description=body.description,
- request_context=request_context,
- )
- if not updated:
- raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
- return _prepare_mental_model_response(updated)
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.post(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh",
- response_model=AsyncOperationSubmitResponse,
- summary="Refresh mental model content (async)",
- description="Submit a background job to refresh content for a specific mental model. "
- "This is useful for newly created learned models or to refresh content for any model.",
- operation_id="refresh_mental_model",
- tags=["Mental Models"],
- )
- async def api_refresh_mental_model(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Refresh content for a specific mental model."""
- try:
- result = await app.state.memory.refresh_mental_model_async(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- return AsyncOperationSubmitResponse(
- operation_id=result["operation_id"],
- status=result.get("status", "queued"),
- )
- except ValueError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/refresh: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.get(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions",
- summary="List mental model version history",
- description="List all saved versions of a mental model's observations, ordered by version descending.",
- operation_id="list_mental_model_versions",
- tags=["Mental Models"],
- )
- async def api_list_mental_model_versions(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """List version history for a mental model."""
- try:
- versions = await app.state.memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- return {"versions": versions}
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.get(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}",
- summary="Get specific mental model version",
- description="Get observations from a specific version of a mental model.",
- operation_id="get_mental_model_version",
- tags=["Mental Models"],
- )
- async def api_get_mental_model_version(
- bank_id: str,
- model_id: str,
- version: int,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Get a specific version of a mental model."""
- try:
- version_data = await app.state.memory.get_mental_model_version(
- bank_id=bank_id,
- model_id=model_id,
- version=version,
- request_context=request_context,
- )
- if not version_data:
- raise HTTPException(
- status_code=404,
- detail=f"Version {version} not found for mental model '{model_id}'",
- )
- return version_data
+ if reflection is None:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return ReflectionResponse(**reflection)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -2640,10 +2382,254 @@ def _register_routes(app: FastAPI):
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
- f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}: {error_detail}"
+ f"Error in POST /v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
+ @app.patch(
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
+ response_model=ReflectionResponse,
+ summary="Update reflection",
+ description="Update a reflection's name.",
+ operation_id="update_reflection",
+ tags=["Reflections"],
+ )
+ async def api_update_reflection(
+ bank_id: str,
+ reflection_id: str,
+ body: UpdateReflectionRequest,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Update a reflection."""
+ try:
+ reflection = await app.state.memory.update_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ name=body.name,
+ request_context=request_context,
+ )
+ if reflection is None:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return ReflectionResponse(**reflection)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.delete(
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
+ summary="Delete reflection",
+ description="Delete a reflection.",
+ operation_id="delete_reflection",
+ tags=["Reflections"],
+ )
+ async def api_delete_reflection(
+ bank_id: str,
+ reflection_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Delete a reflection."""
+ try:
+ deleted = await app.state.memory.delete_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ request_context=request_context,
+ )
+ if not deleted:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return {"status": "deleted"}
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ # =========================================================================
+ # DIRECTIVES ENDPOINTS
+ # =========================================================================
+
+ @app.get(
+ "/v1/default/banks/{bank_id}/directives",
+ response_model=DirectiveListResponse,
+ summary="List directives",
+ description="List hard rules that are injected into prompts.",
+ operation_id="list_directives",
+ tags=["Directives"],
+ )
+ async def api_list_directives(
+ bank_id: str,
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
+ active_only: bool = Query(True, description="Only return active directives"),
+ limit: int = Query(100, ge=1, le=1000),
+ offset: int = Query(0, ge=0),
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """List directives for a bank."""
+ try:
+ directives = await app.state.memory.list_directives(
+ bank_id=bank_id,
+ tags=tags_filter,
+ tags_match=tags_match,
+ active_only=active_only,
+ limit=limit,
+ offset=offset,
+ request_context=request_context,
+ )
+ return DirectiveListResponse(items=[DirectiveResponse(**d) for d in directives])
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.get(
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
+ response_model=DirectiveResponse,
+ summary="Get directive",
+ description="Get a specific directive by ID.",
+ operation_id="get_directive",
+ tags=["Directives"],
+ )
+ async def api_get_directive(
+ bank_id: str,
+ directive_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Get a directive by ID."""
+ try:
+ directive = await app.state.memory.get_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ if directive is None:
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
+ return DirectiveResponse(**directive)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.post(
+ "/v1/default/banks/{bank_id}/directives",
+ response_model=DirectiveResponse,
+ summary="Create directive",
+ description="Create a hard rule that will be injected into prompts.",
+ operation_id="create_directive",
+ tags=["Directives"],
+ )
+ async def api_create_directive(
+ bank_id: str,
+ body: CreateDirectiveRequest,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Create a directive."""
+ try:
+ directive = await app.state.memory.create_directive(
+ bank_id=bank_id,
+ name=body.name,
+ content=body.content,
+ priority=body.priority,
+ is_active=body.is_active,
+ tags=body.tags,
+ request_context=request_context,
+ )
+ return DirectiveResponse(**directive)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/directives: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.patch(
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
+ response_model=DirectiveResponse,
+ summary="Update directive",
+ description="Update a directive's properties.",
+ operation_id="update_directive",
+ tags=["Directives"],
+ )
+ async def api_update_directive(
+ bank_id: str,
+ directive_id: str,
+ body: UpdateDirectiveRequest,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Update a directive."""
+ try:
+ directive = await app.state.memory.update_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ name=body.name,
+ content=body.content,
+ priority=body.priority,
+ is_active=body.is_active,
+ tags=body.tags,
+ request_context=request_context,
+ )
+ if directive is None:
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
+ return DirectiveResponse(**directive)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.delete(
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
+ summary="Delete directive",
+ description="Delete a directive.",
+ operation_id="delete_directive",
+ tags=["Directives"],
+ )
+ async def api_delete_directive(
+ bank_id: str,
+ directive_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Delete a directive."""
+ try:
+ deleted = await app.state.memory.delete_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ if not deleted:
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
+ return {"status": "deleted"}
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
@app.get(
"/v1/default/banks/{bank_id}/documents",
response_model=ListDocumentsResponse,
@@ -2844,17 +2830,27 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations",
response_model=OperationsListResponse,
summary="List async operations",
- description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
+ description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
operation_id="list_operations",
tags=["Operations"],
)
- async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
- """List all async operations (pending and failed) for a memory bank."""
+ async def api_list_operations(
+ bank_id: str,
+ status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
+ limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
+ offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """List async operations for a memory bank with optional filtering and pagination."""
try:
- result = await app.state.memory.list_operations(bank_id, request_context=request_context)
+ result = await app.state.memory.list_operations(
+ bank_id, status=status, limit=limit, offset=offset, request_context=request_context
+ )
return OperationsListResponse(
bank_id=bank_id,
total=result["total"],
+ limit=limit,
+ offset=offset,
operations=[OperationResponse(**op) for op in result["operations"]],
)
except (AuthenticationError, HTTPException):
@@ -3175,6 +3171,63 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
+ @app.delete(
+ "/v1/default/banks/{bank_id}/mental-models",
+ response_model=DeleteResponse,
+ summary="Clear all mental models",
+ description="Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.",
+ operation_id="clear_mental_models",
+ tags=["Banks"],
+ )
+ async def api_clear_mental_models(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
+ """Clear all mental models for a bank."""
+ try:
+ result = await app.state.memory.clear_mental_models(bank_id, request_context=request_context)
+ return DeleteResponse(
+ success=True,
+ message=f"Cleared {result.get('deleted_count', 0)} mental models",
+ deleted_count=result.get("deleted_count", 0),
+ )
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.post(
+ "/v1/default/banks/{bank_id}/consolidate",
+ response_model=ConsolidationResponse,
+ summary="Trigger consolidation",
+ description="Run memory consolidation to create/update mental models from recent memories.",
+ operation_id="trigger_consolidation",
+ tags=["Banks"],
+ )
+ async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
+ """Trigger consolidation for a bank."""
+ try:
+ result = await app.state.memory.run_consolidation(bank_id, request_context=request_context)
+ processed = result.get("processed", 0)
+ created = result.get("created", 0)
+ updated = result.get("updated", 0)
+ return ConsolidationResponse(
+ status="completed",
+ processed=processed,
+ created=created,
+ updated=updated,
+ message=f"Consolidation completed: {processed} memories processed, {created} mental models created, {updated} updated",
+ )
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidate: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
@app.post(
"/v1/default/banks/{bank_id}/memories",
response_model=RetainResponse,
diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py
index d0992812..44df44f2 100644
--- a/hindsight-api/hindsight_api/config.py
+++ b/hindsight-api/hindsight_api/config.py
@@ -93,6 +93,11 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
+# Mental models settings
+ENV_ENABLE_MENTAL_MODELS = "HINDSIGHT_API_ENABLE_MENTAL_MODELS"
+ENV_CONSOLIDATION_SIMILARITY_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_SIMILARITY_THRESHOLD"
+ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
+
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
@@ -171,6 +176,11 @@ DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "ver
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
+# Mental models defaults
+DEFAULT_ENABLE_MENTAL_MODELS = False # Mental models disabled by default (experimental)
+DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD = 0.75 # Minimum similarity to consider a learning related
+DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
+
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
@@ -324,6 +334,11 @@ class HindsightConfig:
retain_extraction_mode: str
retain_observations_async: bool
+ # Mental models settings
+ enable_mental_models: bool
+ consolidation_similarity_threshold: float
+ consolidation_batch_size: int
+
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
@@ -426,6 +441,15 @@ class HindsightConfig:
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).lower()
== "true",
+ # Mental models settings
+ enable_mental_models=os.getenv(ENV_ENABLE_MENTAL_MODELS, str(DEFAULT_ENABLE_MENTAL_MODELS)).lower()
+ == "true",
+ consolidation_similarity_threshold=float(
+ os.getenv(ENV_CONSOLIDATION_SIMILARITY_THRESHOLD, str(DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD))
+ ),
+ consolidation_batch_size=int(
+ os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
+ ),
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
# Database connection pool
diff --git a/hindsight-api/hindsight_api/engine/consolidation/__init__.py b/hindsight-api/hindsight_api/engine/consolidation/__init__.py
new file mode 100644
index 00000000..7d98825c
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/consolidation/__init__.py
@@ -0,0 +1,5 @@
+"""Consolidation engine for automatic learning creation from memories."""
+
+from .consolidator import run_consolidation_job
+
+__all__ = ["run_consolidation_job"]
diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
new file mode 100644
index 00000000..9b7ec6e3
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
@@ -0,0 +1,842 @@
+"""Consolidation engine for automatic mental model creation from memories.
+
+The consolidation engine runs as a background job after retain operations complete.
+It processes new memories and either:
+- Creates new mental models from novel facts
+- Updates existing mental models when new evidence supports/contradicts/refines them
+
+Mental models are stored in memory_units with fact_type='mental_model' and include:
+- proof_count: Number of supporting memories
+- source_memory_ids: Array of memory UUIDs that contribute to this mental model
+- history: JSONB tracking changes over time
+"""
+
+import json
+import logging
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING, Any
+
+from ..memory_engine import fq_table
+from ..retain import embedding_utils
+from .prompts import (
+ CONSOLIDATION_SYSTEM_PROMPT,
+ CONSOLIDATION_USER_PROMPT,
+)
+
+if TYPE_CHECKING:
+ from asyncpg import Connection
+
+ from ...api.http import RequestContext
+ from ..memory_engine import MemoryEngine
+
+logger = logging.getLogger(__name__)
+
+
+class ConsolidationPerfLog:
+ """Performance logging for consolidation operations."""
+
+ def __init__(self, bank_id: str):
+ self.bank_id = bank_id
+ self.start_time = time.time()
+ self.lines: list[str] = []
+ self.timings: dict[str, float] = {}
+
+ def log(self, message: str) -> None:
+ """Add a log line."""
+ self.lines.append(message)
+
+ def record_timing(self, key: str, duration: float) -> None:
+ """Record a timing measurement."""
+ if key in self.timings:
+ self.timings[key] += duration
+ else:
+ self.timings[key] = duration
+
+ def flush(self) -> None:
+ """Flush all log lines to the logger."""
+ total_time = time.time() - self.start_time
+ header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}"
+ footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}"
+
+ log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer
+ logger.info(log_output)
+
+
+async def run_consolidation_job(
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ request_context: "RequestContext",
+) -> dict[str, Any]:
+ """
+ Run consolidation job for a bank.
+
+ This is called after retain operations to consolidate new memories into mental models.
+
+ Args:
+ memory_engine: MemoryEngine instance
+ bank_id: Bank identifier
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with consolidation results
+ """
+ from ...config import get_config
+
+ config = get_config()
+ perf = ConsolidationPerfLog(bank_id)
+ max_memories_per_batch = config.consolidation_batch_size
+
+ # Check if consolidation is enabled
+ if not config.enable_mental_models:
+ logger.debug(f"Consolidation disabled for bank {bank_id}")
+ return {"status": "disabled", "bank_id": bank_id}
+
+ async with memory_engine._pool.acquire() as conn:
+ # Get bank profile and last_consolidated_at
+ t0 = time.time()
+ bank_row = await conn.fetchrow(
+ f"""
+ SELECT bank_id, name, mission, last_consolidated_at
+ FROM {fq_table("banks")}
+ WHERE bank_id = $1
+ """,
+ bank_id,
+ )
+
+ if not bank_row:
+ logger.warning(f"Bank {bank_id} not found for consolidation")
+ return {"status": "bank_not_found", "bank_id": bank_id}
+
+ mission = bank_row["mission"] or "General memory consolidation"
+ last_consolidated_at = bank_row["last_consolidated_at"]
+ perf.record_timing("fetch_bank", time.time() - t0)
+
+ # Fetch memories created after last_consolidated_at (exclude mental_model type)
+ t0 = time.time()
+ if last_consolidated_at:
+ memories = await conn.fetch(
+ f"""
+ SELECT id, text, fact_type, occurred_start, event_date, tags
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND created_at > $2
+ AND fact_type IN ('experience', 'world')
+ ORDER BY created_at ASC
+ LIMIT $3
+ """,
+ bank_id,
+ last_consolidated_at,
+ max_memories_per_batch,
+ )
+ else:
+ memories = await conn.fetch(
+ f"""
+ SELECT id, text, fact_type, occurred_start, event_date, tags
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1
+ AND fact_type IN ('experience', 'world')
+ ORDER BY created_at ASC
+ LIMIT $2
+ """,
+ bank_id,
+ max_memories_per_batch,
+ )
+ perf.record_timing("fetch_memories", time.time() - t0)
+
+ if not memories:
+ logger.debug(f"No new memories to consolidate for bank {bank_id}")
+ # Update timestamp anyway to prevent reprocessing
+ await _update_last_consolidated_at(conn, bank_id)
+ return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
+
+ logger.info(
+ f"[CONSOLIDATION] bank={bank_id} memories={len(memories)} "
+ f"batch_size={max_memories_per_batch} since={last_consolidated_at or 'beginning'}"
+ )
+ perf.log(f"[1] Found {len(memories)} pending memories to consolidate")
+
+ # Process each memory sequentially
+ # Important: We process ALL pending memories before updating the watermark
+ # to avoid losing memories when many have the same timestamp
+ stats = {
+ "memories_processed": 0,
+ "mental_models_created": 0,
+ "mental_models_updated": 0,
+ "mental_models_merged": 0,
+ "actions_executed": 0, # Total actions (can be > memories_processed due to multiple actions per fact)
+ "skipped": 0,
+ }
+
+ # Track processed memory IDs to avoid reprocessing
+ processed_ids: set[uuid.UUID] = set()
+ batch_num = 0
+
+ while memories:
+ batch_num += 1
+ batch_start = time.time()
+
+ for memory in memories:
+ if memory["id"] in processed_ids:
+ continue
+
+ mem_start = time.time()
+ result = await _process_memory(
+ conn=conn,
+ memory_engine=memory_engine,
+ bank_id=bank_id,
+ memory=dict(memory),
+ mission=mission,
+ request_context=request_context,
+ perf=perf,
+ )
+ mem_time = time.time() - mem_start
+ perf.record_timing("process_memory_total", mem_time)
+
+ processed_ids.add(memory["id"])
+ stats["memories_processed"] += 1
+
+ action = result.get("action")
+ if action == "created":
+ stats["mental_models_created"] += 1
+ stats["actions_executed"] += 1
+ elif action == "updated":
+ stats["mental_models_updated"] += 1
+ stats["actions_executed"] += 1
+ elif action == "merged":
+ stats["mental_models_merged"] += 1
+ stats["actions_executed"] += 1
+ elif action == "multiple":
+ # Multiple actions from one fact (tag routing)
+ stats["mental_models_created"] += result.get("created", 0)
+ stats["mental_models_updated"] += result.get("updated", 0)
+ stats["mental_models_merged"] += result.get("merged", 0)
+ stats["actions_executed"] += result.get("total_actions", 0)
+ elif action == "skipped":
+ stats["skipped"] += 1
+
+ batch_time = time.time() - batch_start
+ perf.log(
+ f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
+ f"(avg {batch_time / len(memories):.3f}s/memory)"
+ )
+
+ # Fetch next batch of memories (excluding already processed)
+ t0 = time.time()
+ if last_consolidated_at:
+ memories = await conn.fetch(
+ f"""
+ SELECT id, text, fact_type, occurred_start, event_date, tags
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND created_at > $2
+ AND fact_type IN ('experience', 'world')
+ AND id != ALL($4)
+ ORDER BY created_at ASC
+ LIMIT $3
+ """,
+ bank_id,
+ last_consolidated_at,
+ max_memories_per_batch,
+ list(processed_ids),
+ )
+ else:
+ memories = await conn.fetch(
+ f"""
+ SELECT id, text, fact_type, occurred_start, event_date, tags
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1
+ AND fact_type IN ('experience', 'world')
+ AND id != ALL($3)
+ ORDER BY created_at ASC
+ LIMIT $2
+ """,
+ bank_id,
+ max_memories_per_batch,
+ list(processed_ids),
+ )
+ perf.record_timing("fetch_memories", time.time() - t0)
+
+ # Update last_consolidated_at only after ALL memories are processed
+ t0 = time.time()
+ await _update_last_consolidated_at(conn, bank_id)
+ perf.record_timing("update_watermark", time.time() - t0)
+
+ # Build summary
+ perf.log(
+ f"[3] Results: {stats['memories_processed']} memories → "
+ f"{stats['actions_executed']} actions "
+ f"({stats['mental_models_created']} created, "
+ f"{stats['mental_models_updated']} updated, "
+ f"{stats['mental_models_merged']} merged, "
+ f"{stats['skipped']} skipped)"
+ )
+
+ # Add timing breakdown
+ timing_parts = []
+ if "recall" in perf.timings:
+ timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
+ if "llm" in perf.timings:
+ timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
+ if "embedding" in perf.timings:
+ timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
+ if "db_write" in perf.timings:
+ timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
+
+ if timing_parts:
+ perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
+
+ perf.flush()
+
+ return {"status": "completed", "bank_id": bank_id, **stats}
+
+
+async def _update_last_consolidated_at(conn: "Connection", bank_id: str) -> None:
+ """Update the bank's last_consolidated_at timestamp."""
+ await conn.execute(
+ f"""
+ UPDATE {fq_table("banks")}
+ SET last_consolidated_at = $1
+ WHERE bank_id = $2
+ """,
+ datetime.now(timezone.utc),
+ bank_id,
+ )
+
+
+async def _process_memory(
+ conn: "Connection",
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ memory: dict[str, Any],
+ mission: str,
+ request_context: "RequestContext",
+ perf: ConsolidationPerfLog | None = None,
+) -> dict[str, Any]:
+ """
+ Process a single memory for consolidation using a SINGLE LLM call.
+
+ This function:
+ 1. Finds related mental models (can be empty)
+ 2. Uses ONE LLM call to extract durable knowledge AND decide on actions
+ 3. Executes array of actions (can be multiple creates/updates)
+
+ The LLM handles all cases:
+ - No related models: returns create action(s) with extracted durable knowledge
+ - Related models exist: returns update/create actions based on tag routing
+ - Purely ephemeral fact: returns empty array (skip)
+
+ Returns:
+ Dict with action summary: created/updated/merged counts
+ """
+ fact_text = memory["text"]
+ memory_id = memory["id"]
+ fact_tags = memory.get("tags") or []
+
+ # Find related mental models using the full recall system (NO tag filtering)
+ t0 = time.time()
+ related_mental_models = await _find_related_mental_models(
+ conn=conn,
+ memory_engine=memory_engine,
+ bank_id=bank_id,
+ query=fact_text,
+ request_context=request_context,
+ )
+ if perf:
+ perf.record_timing("recall", time.time() - t0)
+
+ # Single LLM call handles ALL cases (with or without existing models)
+ t0 = time.time()
+ actions = await _consolidate_with_llm(
+ memory_engine=memory_engine,
+ fact_text=fact_text,
+ fact_tags=fact_tags,
+ mental_models=related_mental_models, # Can be empty list
+ mission=mission,
+ )
+ if perf:
+ perf.record_timing("llm", time.time() - t0)
+
+ if not actions:
+ # LLM returned empty array - fact is purely ephemeral, skip
+ return {"action": "skipped", "reason": "no_durable_knowledge"}
+
+ # Execute all actions and collect results
+ results = []
+ for action in actions:
+ action_type = action.get("action")
+ if action_type == "update":
+ result = await _execute_update_action(
+ conn=conn,
+ memory_engine=memory_engine,
+ bank_id=bank_id,
+ memory_id=memory_id,
+ action=action,
+ mental_models=related_mental_models,
+ perf=perf,
+ )
+ results.append(result)
+ elif action_type == "create":
+ result = await _execute_create_action(
+ conn=conn,
+ memory_engine=memory_engine,
+ bank_id=bank_id,
+ memory_id=memory_id,
+ action=action,
+ event_date=memory.get("event_date"),
+ occurred_start=memory.get("occurred_start"),
+ perf=perf,
+ )
+ results.append(result)
+
+ if not results:
+ # No valid actions executed
+ return {"action": "skipped", "reason": "no_valid_actions"}
+
+ # Summarize results
+ created = sum(1 for r in results if r.get("action") == "created")
+ updated = sum(1 for r in results if r.get("action") == "updated")
+ merged = sum(1 for r in results if r.get("action") == "merged")
+
+ if len(results) == 1:
+ return results[0]
+
+ return {
+ "action": "multiple",
+ "created": created,
+ "updated": updated,
+ "merged": merged,
+ "total_actions": len(results),
+ }
+
+
+async def _execute_update_action(
+ conn: "Connection",
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ memory_id: uuid.UUID,
+ action: dict[str, Any],
+ mental_models: list[dict[str, Any]],
+ perf: ConsolidationPerfLog | None = None,
+) -> dict[str, Any]:
+ """
+ Execute an update action on an existing mental model.
+
+ Updates the mental model text, adds to history, and increments proof_count.
+ """
+ learning_id = action.get("learning_id")
+ new_text = action.get("text")
+ reason = action.get("reason", "Updated with new fact")
+
+ if not learning_id or not new_text:
+ return {"action": "skipped", "reason": "missing_learning_id_or_text"}
+
+ # Find the mental model
+ model = next((m for m in mental_models if str(m["id"]) == learning_id), None)
+ if not model:
+ return {"action": "skipped", "reason": "learning_not_found"}
+
+ # Build history entry
+ history = list(model.get("history", []))
+ history.append(
+ {
+ "previous_text": model["text"],
+ "changed_at": datetime.now(timezone.utc).isoformat(),
+ "reason": reason,
+ "source_memory_id": str(memory_id),
+ }
+ )
+
+ # Update source_memory_ids
+ source_ids = list(model.get("source_memory_ids", []))
+ source_ids.append(memory_id)
+
+ # Generate new embedding for updated text
+ t0 = time.time()
+ embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
+ embedding_str = str(embeddings[0]) if embeddings else None
+ if perf:
+ perf.record_timing("embedding", time.time() - t0)
+
+ # Update the mental model
+ t0 = time.time()
+ await conn.execute(
+ f"""
+ UPDATE {fq_table("memory_units")}
+ SET text = $1,
+ embedding = $2::vector,
+ history = $3,
+ source_memory_ids = $4,
+ proof_count = $5,
+ updated_at = now()
+ WHERE id = $6
+ """,
+ new_text,
+ embedding_str,
+ json.dumps(history),
+ source_ids,
+ len(source_ids),
+ uuid.UUID(learning_id),
+ )
+
+ # Create links from memory to mental model
+ await _create_memory_links(conn, memory_id, uuid.UUID(learning_id))
+ if perf:
+ perf.record_timing("db_write", time.time() - t0)
+
+ logger.debug(f"Updated mental model {learning_id} with memory {memory_id}")
+
+ return {"action": "updated", "mental_model_id": learning_id}
+
+
+async def _execute_create_action(
+ conn: "Connection",
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ memory_id: uuid.UUID,
+ action: dict[str, Any],
+ event_date: datetime | None = None,
+ occurred_start: datetime | None = None,
+ perf: ConsolidationPerfLog | None = None,
+) -> dict[str, Any]:
+ """
+ Execute a create action for a new mental model.
+
+ Creates a new mental model with the specified text and tags.
+ The text comes directly from the classify LLM - no second LLM call needed.
+ """
+ text = action.get("text")
+ tags = action.get("tags", [])
+
+ if not text:
+ return {"action": "skipped", "reason": "missing_text"}
+
+ # Use text directly from classify - skip the redundant LLM call
+ result = await _create_mental_model_directly(
+ conn=conn,
+ memory_engine=memory_engine,
+ bank_id=bank_id,
+ source_memory_id=memory_id,
+ mental_model_text=text, # Text already processed by classify LLM
+ tags=tags,
+ event_date=event_date,
+ occurred_start=occurred_start,
+ perf=perf,
+ )
+
+ logger.debug(f"Created mental model {result.get('mental_model_id')} from memory {memory_id} (tags: {tags})")
+
+ return result
+
+
+async def _create_memory_links(
+ conn: "Connection",
+ memory_id: uuid.UUID,
+ mental_model_id: uuid.UUID,
+) -> None:
+ """
+ Create links between a source memory and its mental model.
+
+ This:
+ 1. Creates bidirectional semantic links between memory and mental model
+ 2. Copies existing memory_links from the source memory to the mental model
+ 3. Copies entity links from the source memory to the mental model
+
+ This enables graph traversal to find related memories via their mental models.
+
+ Note: Uses EXISTS checks to handle the case where source memory was deleted
+ by a concurrent operation between fetching and link creation.
+ """
+ mu_table = fq_table("memory_units")
+ ml_table = fq_table("memory_links")
+ ue_table = fq_table("unit_entities")
+
+ # 1. Bidirectional link between memory and mental model
+ # Only insert if both units exist (handles concurrent deletion)
+ await conn.execute(
+ f"""
+ INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
+ SELECT $1, $2, 'semantic', 1.0
+ WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
+ ON CONFLICT DO NOTHING
+ """,
+ memory_id,
+ mental_model_id,
+ )
+ await conn.execute(
+ f"""
+ INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
+ SELECT $1, $2, 'semantic', 1.0
+ WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
+ ON CONFLICT DO NOTHING
+ """,
+ mental_model_id,
+ memory_id,
+ )
+
+ # 2. Copy outgoing memory_links from source memory to mental model
+ # If source memory links to X, mental model should also link to X
+ await conn.execute(
+ f"""
+ INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
+ SELECT $1, ml.to_unit_id, ml.link_type, ml.entity_id, ml.weight
+ FROM {ml_table} ml
+ WHERE ml.from_unit_id = $2 AND ml.to_unit_id != $1
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.to_unit_id)
+ ON CONFLICT DO NOTHING
+ """,
+ mental_model_id,
+ memory_id,
+ )
+
+ # 3. Copy incoming memory_links from source memory to mental model
+ # If X links to source memory, X should also link to mental model
+ await conn.execute(
+ f"""
+ INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
+ SELECT ml.from_unit_id, $1, ml.link_type, ml.entity_id, ml.weight
+ FROM {ml_table} ml
+ WHERE ml.to_unit_id = $2 AND ml.from_unit_id != $1
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.from_unit_id)
+ ON CONFLICT DO NOTHING
+ """,
+ mental_model_id,
+ memory_id,
+ )
+
+ # 4. Copy entity links from source memory to mental model
+ await conn.execute(
+ f"""
+ INSERT INTO {ue_table} (unit_id, entity_id)
+ SELECT $1, ue.entity_id
+ FROM {ue_table} ue
+ WHERE ue.unit_id = $2
+ AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
+ ON CONFLICT DO NOTHING
+ """,
+ mental_model_id,
+ memory_id,
+ )
+
+
+async def _find_related_mental_models(
+ conn: "Connection",
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ query: str,
+ request_context: "RequestContext",
+) -> list[dict[str, Any]]:
+ """
+ Find mental models related to the given query using the full recall system.
+
+ IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
+ potentially related mental models regardless of scope, so the LLM can
+ decide on tag routing (same scope update vs cross-scope create).
+
+ This leverages:
+ - Semantic search (embedding similarity)
+ - BM25 text search (keyword matching)
+ - Entity-based retrieval (shared entities)
+ - Graph traversal (connected via entity links)
+
+ Returns:
+ List of related mental models with their tags for LLM tag routing
+ """
+ # Use recall to find related mental models
+ # NO tags parameter - we want ALL mental models regardless of scope
+ # Use low max_tokens since we only need mental models, not memories
+ recall_result = await memory_engine.recall_async(
+ bank_id=bank_id,
+ query=query,
+ max_tokens=5000, # Token budget for mental models
+ fact_type=["mental_model"], # Only retrieve mental models
+ request_context=request_context,
+ _quiet=True, # Suppress logging
+ # NO tags parameter - intentionally get ALL mental models
+ )
+
+ # If no mental models returned, return empty list
+ # When fact_type=["mental_model"], results come back in `results` field
+ if not recall_result.results:
+ return []
+
+ # Trust recall's relevance filtering - fetch full data for each mental model
+ results = []
+ for mm in recall_result.results:
+ # Fetch full mental model data from DB to get history, source_memory_ids, tags
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
+ FROM {fq_table("memory_units")}
+ WHERE id = $1 AND bank_id = $2 AND fact_type = 'mental_model'
+ """,
+ uuid.UUID(mm.id),
+ bank_id,
+ )
+
+ if row:
+ history = row["history"]
+ if isinstance(history, str):
+ history = json.loads(history)
+ elif history is None:
+ history = []
+
+ results.append(
+ {
+ "id": row["id"],
+ "text": row["text"],
+ "proof_count": row["proof_count"] or 1,
+ "history": history,
+ "tags": row["tags"] or [], # Include tags for LLM tag routing
+ "source_memory_ids": row["source_memory_ids"] or [],
+ "similarity": 1.0, # Retrieved via recall so assumed relevant
+ }
+ )
+
+ return results
+
+
+async def _consolidate_with_llm(
+ memory_engine: "MemoryEngine",
+ fact_text: str,
+ fact_tags: list[str],
+ mental_models: list[dict[str, Any]],
+ mission: str,
+) -> list[dict[str, Any]]:
+ """
+ Single LLM call to extract durable knowledge and decide on consolidation actions.
+
+ This handles ALL cases:
+ - No related mental models: extracts durable knowledge, returns create action
+ - Related models exist: compares and returns update/create actions
+ - Purely ephemeral fact: returns empty array
+
+ Returns:
+ List of actions, each being:
+ - {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
+ - {"action": "create", "tags": [...], "text": "...", "reason": "..."}
+ - [] if fact is purely ephemeral (no durable knowledge)
+ """
+ # Format mental models WITH their tags (or "None" if empty)
+ if mental_models:
+ mental_models_text = "\n".join(
+ f'- ID: {mm["id"]}, Tags: {json.dumps(mm["tags"])}, Text: "{mm["text"]}" (proof_count: {mm["proof_count"]})'
+ for mm in mental_models
+ )
+ else:
+ mental_models_text = "None (this is a new topic - create if fact contains durable knowledge)"
+
+ # Only include mission section if mission is set and not the default
+ mission_section = ""
+ if mission and mission != "General memory consolidation":
+ mission_section = f"""
+MISSION CONTEXT: {mission}
+
+Focus on DURABLE knowledge that serves this mission, not ephemeral state.
+"""
+
+ user_prompt = CONSOLIDATION_USER_PROMPT.format(
+ mission_section=mission_section,
+ fact_text=fact_text,
+ fact_tags=json.dumps(fact_tags),
+ mental_models_text=mental_models_text,
+ )
+
+ messages = [
+ {"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ]
+
+ try:
+ result = await memory_engine._llm_config.call(
+ messages=messages,
+ skip_validation=True, # Raw JSON response
+ scope="consolidation",
+ )
+ # Parse JSON response - should be an array
+ if isinstance(result, str):
+ result = json.loads(result)
+ # Ensure result is a list
+ if isinstance(result, list):
+ return result
+ # Handle legacy single-action format for backward compatibility
+ if isinstance(result, dict):
+ if result.get("related_ids") and result.get("consolidated_text"):
+ # Convert old format to new format
+ return [
+ {
+ "action": "update",
+ "learning_id": result["related_ids"][0],
+ "text": result["consolidated_text"],
+ "reason": result.get("reason", ""),
+ }
+ ]
+ return []
+ return []
+ except Exception as e:
+ logger.warning(f"Error in consolidation LLM call: {e}")
+ return []
+
+
+async def _create_mental_model_directly(
+ conn: "Connection",
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ source_memory_id: uuid.UUID,
+ mental_model_text: str,
+ tags: list[str] | None = None,
+ event_date: datetime | None = None,
+ occurred_start: datetime | None = None,
+ perf: ConsolidationPerfLog | None = None,
+) -> dict[str, Any]:
+ """
+ Create a mental model directly with pre-processed text (no LLM call).
+
+ Used when the classify LLM has already provided the learning text.
+ This avoids the redundant second LLM call.
+ """
+ # Generate embedding for the mental model (convert to string for pgvector)
+ t0 = time.time()
+ embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [mental_model_text])
+ embedding_str = str(embeddings[0]) if embeddings else None
+ if perf:
+ perf.record_timing("embedding", time.time() - t0)
+
+ # Create the mental model as a memory_unit
+ now = datetime.now(timezone.utc)
+ mm_event_date = event_date or now
+ mm_occurred_start = occurred_start or now
+ mm_tags = tags or []
+
+ t0 = time.time()
+ mental_model_id = uuid.uuid4()
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("memory_units")} (
+ id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
+ tags, event_date, occurred_start
+ )
+ VALUES ($1, $2, $3, 'mental_model', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8)
+ RETURNING id
+ """,
+ mental_model_id,
+ bank_id,
+ mental_model_text,
+ embedding_str,
+ [source_memory_id],
+ mm_tags,
+ mm_event_date,
+ mm_occurred_start,
+ )
+
+ # Create links between memory and mental model (includes entity links, memory_links)
+ await _create_memory_links(conn, source_memory_id, mental_model_id)
+ if perf:
+ perf.record_timing("db_write", time.time() - t0)
+
+ logger.debug(f"Created mental model {mental_model_id} from memory {source_memory_id} (tags: {mm_tags})")
+
+ return {"action": "created", "mental_model_id": str(row["id"]), "tags": mm_tags}
diff --git a/hindsight-api/hindsight_api/engine/consolidation/prompts.py b/hindsight-api/hindsight_api/engine/consolidation/prompts.py
new file mode 100644
index 00000000..59e12977
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/consolidation/prompts.py
@@ -0,0 +1,91 @@
+"""Prompts for the consolidation engine."""
+
+CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (mental models) and merge with existing knowledge when appropriate.
+
+You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
+
+## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
+Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
+
+Examples of extracting durable knowledge:
+- "User moved to Room 203" -> "Room 203 exists" (location exists, not where user is now)
+- "User visited Acme Corp at Room 105" -> "Acme Corp is located in Room 105"
+- "User took the elevator to floor 3" -> "Floor 3 is accessible by elevator"
+- "User met Sarah at the lobby" -> "Sarah can be found at the lobby"
+
+DO NOT track current user position/state as knowledge - that changes constantly.
+DO track permanent facts learned from the user's actions.
+
+## PRESERVE SPECIFIC DETAILS
+Keep names, locations, numbers, and other specifics. Do NOT:
+- Abstract into general principles
+- Generate business insights
+- Make knowledge generic
+
+GOOD examples:
+- Fact: "John likes pizza" -> "John likes pizza"
+- Fact: "Alice works at Google" -> "Alice works at Google"
+
+BAD examples:
+- "John likes pizza" -> "Understanding dietary preferences helps..." (TOO ABSTRACT)
+- "User is at Room 203" -> "User is currently at Room 203" (EPHEMERAL STATE)
+
+## MERGE RULES (when comparing to existing mental models):
+1. REDUNDANT: Same information worded differently → update existing
+2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
+3. UPDATE: New state replacing old state → update with history
+
+## TAG ROUTING RULES:
+Tags define visibility scopes. The fact and each mental model have tags (can be empty = global).
+
+| Fact Tags | Model Tags | Action |
+|-----------|------------|--------|
+| [alice] | [alice] | UPDATE the model (same scope) |
+| [alice] | [] | UPDATE the model (global absorbs all scopes) |
+| [alice] | [bob] | CREATE new untagged model (cross-scope insight) |
+| [] | [alice] | UPDATE the model (untagged facts can update any scope) |
+| [] | [] | UPDATE the model (global to global) |
+
+When NO existing model matches the fact's topic: CREATE new model with fact's tags.
+
+## MULTIPLE ACTIONS:
+One fact can trigger MULTIPLE actions. For example:
+- Update a scoped model [alice] about pizza preferences
+- AND update a global model [] about pizza in general
+
+Output an ARRAY of actions (can be empty, one, or many).
+
+## CRITICAL RULES:
+- NEVER merge facts about DIFFERENT people
+- NEVER merge unrelated topics (food preferences vs work vs hobbies)
+- When merging contradictions, capture the CHANGE (before → after)
+- Keep mental models focused on ONE specific topic per person
+- Cross-scope insights (alice's fact about bob's topic) become UNTAGGED (global)
+- The "text" field MUST contain durable knowledge, not ephemeral state"""
+
+CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge.
+{mission_section}
+NEW FACT: {fact_text}
+FACT TAGS: {fact_tags}
+
+EXISTING MENTAL MODELS:
+{mental_models_text}
+
+Instructions:
+1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X")
+2. Then compare with existing mental models:
+ - If a model covers the same topic: UPDATE it with the new knowledge
+ - If no model covers the topic: CREATE a new one
+ - If fact is about different scope: apply tag routing rules
+
+Output JSON array of actions (ALWAYS an array, even for single action):
+[
+ {{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
+ {{"action": "create", "tags": ["tag"], "text": "new durable knowledge", "reason": "..."}}
+]
+
+If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
+[]
+
+If no models exist and fact contains durable knowledge:
+[{{"action": "create", "tags": {fact_tags}, "text": "durable knowledge text", "reason": "new topic"}}]"""
diff --git a/hindsight-api/hindsight_api/engine/directives/__init__.py b/hindsight-api/hindsight_api/engine/directives/__init__.py
new file mode 100644
index 00000000..6ca566f0
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/directives/__init__.py
@@ -0,0 +1,5 @@
+"""Directives module for hard rules injected into prompts."""
+
+from .models import Directive
+
+__all__ = ["Directive"]
diff --git a/hindsight-api/hindsight_api/engine/directives/models.py b/hindsight-api/hindsight_api/engine/directives/models.py
new file mode 100644
index 00000000..f8d7d763
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/directives/models.py
@@ -0,0 +1,37 @@
+"""Pydantic models for directives."""
+
+from datetime import datetime, timezone
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+
+class Directive(BaseModel):
+ """A directive is a hard rule injected into prompts.
+
+ Directives are user-defined rules that guide agent behavior. Unlike mental models
+ which are automatically consolidated from memories, directives are explicit
+ instructions that are always included in relevant prompts.
+
+ Examples:
+ - "Always respond in formal English"
+ - "Never share personal data with third parties"
+ - "Prefer conservative investment recommendations"
+ """
+
+ id: UUID = Field(description="Unique identifier")
+ bank_id: str = Field(description="Bank this directive belongs to")
+ name: str = Field(description="Human-readable name")
+ content: str = Field(description="The directive text to inject into prompts")
+ priority: int = Field(default=0, description="Higher priority directives are injected first")
+ is_active: bool = Field(default=True, description="Whether this directive is currently active")
+ tags: list[str] = Field(default_factory=list, description="Tags for filtering")
+ created_at: datetime = Field(
+ default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created"
+ )
+ updated_at: datetime = Field(
+ default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated"
+ )
+
+ class Config:
+ from_attributes = True
diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py
index f6211416..ecf0f6a4 100644
--- a/hindsight-api/hindsight_api/engine/memory_engine.py
+++ b/hindsight-api/hindsight_api/engine/memory_engine.py
@@ -11,6 +11,7 @@ This implements a sophisticated memory architecture that combines:
import asyncio
import contextvars
+import json
import logging
import time
import uuid
@@ -141,7 +142,7 @@ from .llm_wrapper import LLMConfig
from .query_analyzer import QueryAnalyzer
from .reflect import run_reflect_agent
from .reflect.models import MentalModelInput
-from .reflect.tools import tool_expand, tool_learn, tool_lookup, tool_recall
+from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models
from .response_models import (
VALID_RECALL_FACT_TYPES,
EntityObservation,
@@ -501,269 +502,101 @@ class MemoryEngine(MemoryEngineInterface):
logger.info(f"[BATCH_RETAIN_TASK] Completed background batch retain for bank_id={bank_id}")
- async def _handle_refresh_mental_models(self, task_dict: dict[str, Any]):
+ async def _handle_consolidation(self, task_dict: dict[str, Any]):
"""
- Handler for refresh mental models tasks.
+ Handler for consolidation tasks.
- This is the main background job that:
- 1. Identifies mental models (structural from mission + emergent from entities)
- 2. Generates summaries for each mental model
+ Consolidates new memories into learnings for a bank.
Args:
- task_dict: Dict with 'bank_id', 'operation_id', optional 'tags', optional 'subtype'
- """
- import time
+ task_dict: Dict with 'bank_id'
+ Raises:
+ ValueError: If bank_id is missing
+ Exception: Any exception from consolidation (propagates to execute_task for retry)
+ """
bank_id = task_dict.get("bank_id")
- operation_id = task_dict.get("operation_id")
- tags = task_dict.get("tags") # Tags to apply to created mental models
- subtype = task_dict.get("subtype") # Optional filter: "structural", "emergent", "pinned", or "learned"
if not bank_id:
- raise ValueError("bank_id is required for refresh mental models task")
-
- refresh_structural = subtype is None or subtype == "structural"
- refresh_emergent = subtype is None or subtype == "emergent"
- refresh_pinned = subtype is None or subtype == "pinned"
- refresh_learned = subtype is None or subtype == "learned"
- subtype_desc = f" (subtype={subtype})" if subtype else " (all)"
+ raise ValueError("bank_id is required for consolidation task")
from hindsight_api.models import RequestContext
- internal_context = RequestContext()
- pool = await self._get_pool()
-
- from .mental_models.emergent import (
- detect_entity_candidates,
- evaluate_emergent_models,
- filter_candidates_by_mission,
- )
-
- # ===== Phase 1: Identify mental models (with buffered logging) =====
- phase1_start = time.perf_counter()
- id_log: list[str] = [] # Log buffer for identification phase
-
- # Step 1: Get the bank's mission (required - should have been validated before scheduling)
- profile = await self.get_bank_profile(bank_id, request_context=internal_context)
- mission = profile.get("mission") or ""
- if not mission:
- raise ValueError(f"Cannot refresh mental models: no mission is set for bank '{bank_id}'")
-
- structural_removed: list[str] = []
- emergent_removed: list[str] = []
- emergent_promoted: list[str] = []
-
- # Step 2: Derive structural models (LLM sees existing ones and decides what to keep)
- if refresh_structural:
- existing_structural = await self.list_mental_models(
- bank_id, subtype="structural", request_context=internal_context
- )
- id_log.append(f"structural: {len(existing_structural) if existing_structural else 0} existing")
- models_to_remove = await self._derive_structural_models_internal(
- bank_id, mission, pool, existing_models=existing_structural, tags=tags
- )
- for model_id in models_to_remove:
- structural_removed.append(model_id)
- await self.delete_mental_model(bank_id, model_id, request_context=internal_context)
- if structural_removed:
- id_log.append(f"structural removed: {structural_removed}")
- else:
- id_log.append("structural: skipped (subtype filter)")
-
- # Step 3: Evaluate existing emergent models
- removed_entity_ids: set[str] = set() # Track entity_ids we removed (to prevent re-promotion)
- if refresh_emergent:
- existing_emergent = await self.list_mental_models(
- bank_id, subtype="emergent", request_context=internal_context
- )
- if existing_emergent:
- id_log.append(f"emergent: {len(existing_emergent)} existing")
- # Build model_id -> entity_id mapping for tracking
- model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent}
- models_to_remove = await evaluate_emergent_models(self._llm_config, existing_emergent)
- for model_id in models_to_remove:
- emergent_removed.append(model_id)
- # Track the entity_id so we don't re-promote it
- entity_id = model_to_entity.get(model_id)
- if entity_id:
- removed_entity_ids.add(str(entity_id))
- await self.delete_mental_model(bank_id, model_id, request_context=internal_context)
- if emergent_removed:
- id_log.append(f"emergent removed: {emergent_removed}")
- else:
- id_log.append("emergent: 0 existing")
-
- # Step 4: Detect emergent candidates (entities worth promoting)
- candidates = await detect_entity_candidates(pool, bank_id)
- id_log.append(f"emergent candidates detected: {len(candidates)}")
-
- # Step 5: Filter candidates by mission relevance
- if candidates and mission:
- candidates = await filter_candidates_by_mission(self._llm_config, mission, candidates)
- id_log.append(f"emergent candidates after mission filter: {len(candidates)}")
-
- # Step 6: Filter out candidates whose entity was just removed (they failed evaluation)
- if removed_entity_ids:
- original_count = len(candidates)
- candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
- if len(candidates) < original_count:
- id_log.append(f"emergent excluded (failed evaluation): {original_count - len(candidates)}")
-
- # Step 7: Promote filtered candidates to mental models (with tags if provided)
- for candidate in candidates:
- if candidate.entity_id:
- emergent_promoted.append(candidate.name)
- await self._promote_entity_internal(bank_id, candidate.entity_id, pool, tags=tags)
- if emergent_promoted:
- id_log.append(f"emergent promoted: {emergent_promoted}")
- else:
- id_log.append("emergent: skipped (subtype filter)")
-
- phase1_duration_ms = int((time.perf_counter() - phase1_start) * 1000)
-
- # Output single log for Phase 1
- logger.info(
- f"[MENTAL_MODELS] Identification complete for bank={bank_id}{subtype_desc} "
- f"in {phase1_duration_ms}ms: {', '.join(id_log)}"
- )
-
- # ===== Phase 2: Generate summaries in parallel =====
- models = await self.list_mental_models(bank_id, request_context=internal_context)
-
- # Filter models to only those being refreshed based on subtype
- # NOTE: Directives (subtype='directive') are NEVER refreshed - they have user-provided content
- models_to_refresh = []
- for m in models:
- model_subtype = m["subtype"]
- # Skip directives - they have user-defined content that should never be regenerated
- if model_subtype == "directive":
- continue
- if model_subtype == "structural" and refresh_structural:
- models_to_refresh.append(m)
- elif model_subtype == "emergent" and refresh_emergent:
- models_to_refresh.append(m)
- elif model_subtype == "pinned" and refresh_pinned:
- models_to_refresh.append(m)
- elif model_subtype == "learned" and refresh_learned:
- models_to_refresh.append(m)
-
- # Get concurrency limit from config
- from ..config import get_config
-
- config = get_config()
- concurrency = config.mental_model_refresh_concurrency
-
- # Use semaphore to limit concurrent refreshes
- semaphore = asyncio.Semaphore(concurrency)
- # Track results with timing: model_id -> {status, duration_ms, iterations, tool_calls, observations}
- refresh_results: dict[str, dict[str, Any]] = {}
-
- async def refresh_with_semaphore(model: dict) -> None:
- """Refresh a single model with semaphore-controlled concurrency."""
- async with semaphore:
- model_id = model["id"]
- model_name = model["name"]
- start_time = time.perf_counter()
- try:
- result = await self.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=internal_context,
- _return_agent_result=True, # Get agent stats for logging
- )
- duration_ms = int((time.perf_counter() - start_time) * 1000)
- if result and isinstance(result, tuple):
- _, agent_result = result
- refresh_results[model_id] = {
- "status": "success",
- "name": model_name,
- "duration_ms": duration_ms,
- "phases": len(agent_result.phases_completed) if agent_result else 0,
- "memories_analyzed": agent_result.memories_analyzed if agent_result else 0,
- "observations": len(agent_result.observations) if agent_result else 0,
- }
- else:
- refresh_results[model_id] = {
- "status": "success",
- "name": model_name,
- "duration_ms": duration_ms,
- }
- except Exception as e:
- duration_ms = int((time.perf_counter() - start_time) * 1000)
- refresh_results[model_id] = {
- "status": "failed",
- "name": model_name,
- "duration_ms": duration_ms,
- "error": str(e),
- }
-
- # Run all refreshes in parallel (bounded by semaphore)
- phase2_start = time.perf_counter()
- await asyncio.gather(*[refresh_with_semaphore(m) for m in models_to_refresh])
- phase2_duration_ms = int((time.perf_counter() - phase2_start) * 1000)
-
- # Build summary for each model
- model_summaries: list[str] = []
- for model_id, info in refresh_results.items():
- if info["status"] == "success":
- parts = [f"{info['name']}"]
- if "iterations" in info:
- parts.append(f"iter={info['iterations']}")
- if "tool_calls" in info:
- parts.append(f"tools={info['tool_calls']}")
- if "observations" in info:
- parts.append(f"obs={info['observations']}")
- parts.append(f"{info['duration_ms']}ms")
- model_summaries.append(f"[{' '.join(parts)}]")
- else:
- model_summaries.append(
- f"[{info['name']} FAILED: {info.get('error', 'unknown')} {info['duration_ms']}ms]"
- )
-
- success_count = sum(1 for r in refresh_results.values() if r["status"] == "success")
- failed_count = len(refresh_results) - success_count
-
- # Output single log for Phase 2
- logger.info(
- f"[MENTAL_MODELS] Refresh complete for bank={bank_id}, operation={operation_id}: "
- f"{success_count}/{len(models_to_refresh)} succeeded in {phase2_duration_ms}ms (concurrency={concurrency}). "
- f"Models: {' '.join(model_summaries)}"
- )
-
- async def _handle_refresh_single_mental_model(self, task_dict: dict[str, Any]):
- """
- Handler for single mental model refresh tasks.
-
- Refreshes content for a specific mental model.
-
- Args:
- task_dict: Dict with 'bank_id', 'model_id', 'operation_id'
- """
- bank_id = task_dict.get("bank_id")
- model_id = task_dict.get("model_id")
- operation_id = task_dict.get("operation_id")
-
- if not bank_id or not model_id:
- raise ValueError("bank_id and model_id are required for refresh mental model task")
-
- logger.info(
- f"[MENTAL_MODEL_TASK] Starting refresh for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}"
- )
-
- from hindsight_api.models import RequestContext
+ from .consolidation import run_consolidation_job
internal_context = RequestContext()
-
- # Refresh content for the model
- result = await self.refresh_mental_model(
+ result = await run_consolidation_job(
+ memory_engine=self,
bank_id=bank_id,
- model_id=model_id,
request_context=internal_context,
)
- if result:
- logger.info(f"[MENTAL_MODEL_TASK] Completed refresh for model_id={model_id}, bank_id={bank_id}")
- else:
- logger.warning(f"[MENTAL_MODEL_TASK] Model not found: model_id={model_id}, bank_id={bank_id}")
+ logger.info(f"[CONSOLIDATION] bank={bank_id} completed: {result.get('memories_processed', 0)} processed")
+
+ async def _handle_create_reflection(self, task_dict: dict[str, Any]):
+ """
+ Handler for create_reflection tasks.
+
+ Runs reflect with the source query and updates the reflection with the generated content.
+ The reflection should already exist in the database (created during submit_async_create_reflection).
+
+ Args:
+ task_dict: Dict with 'bank_id', 'reflection_id', 'source_query', 'max_tokens', 'operation_id'
+
+ Raises:
+ ValueError: If required fields are missing
+ Exception: Any exception from reflect/update (propagates to execute_task for retry)
+ """
+ bank_id = task_dict.get("bank_id")
+ reflection_id = task_dict.get("reflection_id")
+ source_query = task_dict.get("source_query")
+ max_tokens = task_dict.get("max_tokens", 2048)
+
+ if not bank_id or not reflection_id or not source_query:
+ raise ValueError("bank_id, reflection_id, and source_query are required for create_reflection task")
+
+ logger.info(f"[CREATE_REFLECTION_TASK] Starting for bank_id={bank_id}, reflection_id={reflection_id}")
+
+ from hindsight_api.models import RequestContext
+
+ internal_context = RequestContext()
+
+ # Run reflect to generate content
+ reflect_result = await self.reflect_async(
+ bank_id=bank_id,
+ query=source_query,
+ max_tokens=max_tokens,
+ request_context=internal_context,
+ )
+
+ generated_content = reflect_result.text or "No content generated"
+
+ # Build reflect_response payload to store
+ reflect_response = {
+ "text": reflect_result.text,
+ "based_on": {
+ fact_type: [
+ {
+ "id": str(fact.id),
+ "text": fact.text,
+ "type": fact_type,
+ }
+ for fact in facts
+ ]
+ for fact_type, facts in reflect_result.based_on.items()
+ },
+ "mental_models": [], # Mental models are included in based_on["mental-models"]
+ }
+
+ # Update the reflection with the generated content and reflect_response
+ await self.update_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ content=generated_content,
+ reflect_response=reflect_response,
+ request_context=internal_context,
+ )
+
+ logger.info(f"[CREATE_REFLECTION_TASK] Completed for bank_id={bank_id}, reflection_id={reflection_id}")
async def execute_task(self, task_dict: dict[str, Any]):
"""
@@ -801,10 +634,10 @@ class MemoryEngine(MemoryEngineInterface):
try:
if task_type == "batch_retain":
await self._handle_batch_retain(task_dict)
- elif task_type == "refresh_mental_models":
- await self._handle_refresh_mental_models(task_dict)
- elif task_type == "refresh_mental_model":
- await self._handle_refresh_single_mental_model(task_dict)
+ elif task_type == "consolidation":
+ await self._handle_consolidation(task_dict)
+ elif task_type == "create_reflection":
+ await self._handle_create_reflection(task_dict)
else:
logger.error(f"Unknown task type: {task_type}")
# Don't retry unknown task types
@@ -1483,6 +1316,17 @@ class MemoryEngine(MemoryEngineInterface):
except Exception as e:
logger.warning(f"Post-retain hook error (non-fatal): {e}")
+ # Trigger consolidation as a tracked async operation if enabled
+ from ..config import get_config
+
+ config = get_config()
+ if config.enable_mental_models:
+ try:
+ await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
+ except Exception as e:
+ # Log but don't fail the retain - consolidation is non-critical
+ logger.warning(f"Failed to submit consolidation task for bank {bank_id}: {e}")
+
if return_usage:
return result, total_usage
return result
@@ -1598,6 +1442,7 @@ class MemoryEngine(MemoryEngineInterface):
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
_connection_budget: int | None = None,
+ _quiet: bool = False,
) -> RecallResultModel:
"""
Recall memories using N*4-way parallel retrieval (N fact types × 4 retrieval methods).
@@ -1680,9 +1525,10 @@ class MemoryEngine(MemoryEngineInterface):
effective_budget = budget if budget is not None else Budget.MID
thinking_budget = budget_mapping[effective_budget]
- # Log recall start with tags if present
- tags_info = f", tags={tags} ({tags_match})" if tags else ""
- logger.info(f"[RECALL {bank_id[:8]}] Starting recall for query: {query[:50]}...{tags_info}")
+ # Log recall start with tags if present (skip if quiet mode for internal operations)
+ if not _quiet:
+ tags_info = f", tags={tags} ({tags_match})" if tags else ""
+ logger.info(f"[RECALL {bank_id[:8]}] Starting recall for query: {query[:50]}...{tags_info}")
# Backpressure: limit concurrent recalls to prevent overwhelming the database
result = None
@@ -1711,6 +1557,7 @@ class MemoryEngine(MemoryEngineInterface):
tags=tags,
tags_match=tags_match,
connection_budget=_connection_budget,
+ quiet=_quiet,
)
break # Success - exit retry loop
except Exception as e:
@@ -1832,6 +1679,7 @@ class MemoryEngine(MemoryEngineInterface):
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
connection_budget: int | None = None,
+ quiet: bool = False,
) -> RecallResultModel:
"""
Search implementation with modular retrieval and reranking.
@@ -2462,13 +2310,15 @@ class MemoryEngine(MemoryEngineInterface):
log_buffer.append(
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
)
- logger.info("\n" + "\n".join(log_buffer))
+ if not quiet:
+ logger.info("\n" + "\n".join(log_buffer))
return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict)
except Exception as e:
log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}")
- logger.error("\n" + "\n".join(log_buffer))
+ if not quiet:
+ logger.error("\n" + "\n".join(log_buffer))
raise Exception(f"Failed to search memories: {str(e)}")
def _filter_by_token_budget(
@@ -2724,6 +2574,79 @@ class MemoryEngine(MemoryEngineInterface):
except Exception as e:
raise Exception(f"Failed to delete agent data: {str(e)}")
+ async def clear_mental_models(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, int]:
+ """
+ Clear all mental models for a bank.
+
+ Args:
+ bank_id: Bank ID to clear mental models for
+ request_context: Request context for authentication.
+
+ Returns:
+ Dictionary with count of deleted mental models
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+ async with acquire_with_retry(pool) as conn:
+ async with conn.transaction():
+ # Count mental models before deletion
+ count = await conn.fetchval(
+ f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'mental_model'",
+ bank_id,
+ )
+
+ # Delete all mental models
+ await conn.execute(
+ f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'mental_model'",
+ bank_id,
+ )
+
+ # Reset consolidation timestamp
+ await conn.execute(
+ f"UPDATE {fq_table('banks')} SET last_consolidated_at = NULL WHERE bank_id = $1",
+ bank_id,
+ )
+
+ return {"deleted_count": count or 0}
+
+ async def run_consolidation(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, int]:
+ """
+ Run memory consolidation to create/update mental models.
+
+ Args:
+ bank_id: Bank ID to run consolidation for
+ request_context: Request context for authentication.
+
+ Returns:
+ Dictionary with consolidation stats
+ """
+ await self._authenticate_tenant(request_context)
+
+ from .consolidation import run_consolidation_job
+
+ result = await run_consolidation_job(
+ memory_engine=self,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ return {
+ "processed": result.get("processed", 0),
+ "created": result.get("created", 0),
+ "updated": result.get("updated", 0),
+ "skipped": result.get("skipped", 0),
+ }
+
async def get_graph_data(
self,
bank_id: str | None = None,
@@ -3417,6 +3340,7 @@ class MemoryEngine(MemoryEngineInterface):
request_context: "RequestContext",
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
+ exclude_reflection_ids: list[str] | None = None,
) -> ReflectResult:
"""
Reflect and formulate an answer using an agentic loop with tools.
@@ -3438,6 +3362,10 @@ class MemoryEngine(MemoryEngineInterface):
context: Additional context string to include in agent prompt
max_tokens: Max tokens (currently unused, reserved for future)
response_schema: Optional JSON Schema for structured output (not yet supported)
+ tags: Optional tags to filter memories
+ tags_match: How to match tags - "any" (OR), "all" (AND)
+ exclude_reflection_ids: Optional list of reflection IDs to exclude from search
+ (used when refreshing a reflection to avoid circular reference)
Returns:
ReflectResult containing:
@@ -3490,46 +3418,72 @@ class MemoryEngine(MemoryEngineInterface):
# (not held during LLM calls which can be slow)
pool = await self._get_pool()
+ # Get bank stats for freshness info
+ bank_stats = await self.get_bank_stats(bank_id, request_context=request_context)
+ last_consolidated_at = bank_stats.last_consolidated_at if hasattr(bank_stats, "last_consolidated_at") else None
+ pending_consolidation = bank_stats.pending_consolidation if hasattr(bank_stats, "pending_consolidation") else 0
+
# Create tool callbacks that acquire connections only when needed
- async def lookup_fn(model_id: str | None = None) -> dict[str, Any]:
+ from .reflect.tools import tool_search_reflections
+ from .retain import embedding_utils
+
+ async def search_reflections_fn(q: str, max_results: int = 5) -> dict[str, Any]:
+ # Generate embedding for the query
+ embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, [q])
+ query_embedding = embeddings[0]
async with pool.acquire() as conn:
- return await tool_lookup(conn, bank_id, model_id, tags=tags, tags_match=tags_match)
+ return await tool_search_reflections(
+ conn,
+ bank_id,
+ q,
+ query_embedding,
+ max_results=max_results,
+ tags=tags,
+ tags_match=tags_match,
+ exclude_ids=exclude_reflection_ids,
+ )
+
+ async def search_mental_models_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
+ return await tool_search_mental_models(
+ self,
+ bank_id,
+ q,
+ request_context,
+ max_tokens=max_tokens,
+ tags=tags,
+ tags_match=tags_match,
+ last_consolidated_at=last_consolidated_at,
+ pending_consolidation=pending_consolidation,
+ )
async def recall_fn(q: str, max_tokens: int = 4096) -> dict[str, Any]:
return await tool_recall(
self, bank_id, q, request_context, max_tokens=max_tokens, tags=tags, tags_match=tags_match
)
- async def learn_fn(input: MentalModelInput) -> dict[str, Any]:
- async with pool.acquire() as conn:
- result = await tool_learn(conn, bank_id, input, tags=tags)
- # If a new model was created, trigger background refresh
- if result.get("status") == "created" and result.get("model_id"):
- try:
- await self.refresh_mental_model_async(
- bank_id=bank_id,
- model_id=result["model_id"],
- request_context=request_context,
- )
- logger.info(f"[REFLECT] Triggered background refresh for learned model: {result['model_id']}")
- except Exception as e:
- logger.warning(f"[REFLECT] Failed to trigger refresh for {result['model_id']}: {e}")
- return result
-
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
async with pool.acquire() as conn:
return await tool_expand(conn, bank_id, memory_ids, depth)
- # Load directives (mental models with subtype='directive')
+ # Load directives from the dedicated directives table
# Directives are hard rules that must be followed in all responses
- # Filter by tags if provided (same logic as other mental models)
- directives = await self.list_mental_models(
+ directives_raw = await self.list_directives(
bank_id=bank_id,
- subtype="directive",
tags=tags,
tags_match=tags_match,
+ active_only=True,
request_context=request_context,
)
+ # Convert directive format to the expected format for reflect agent
+ # The agent expects: name, description (optional), observations (list of {title, content})
+ directives = [
+ {
+ "name": d["name"],
+ "description": d["content"], # Use content as description
+ "observations": [], # Directives use content directly, not observations
+ }
+ for d in directives_raw
+ ]
if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
@@ -3539,9 +3493,9 @@ class MemoryEngine(MemoryEngineInterface):
bank_id=bank_id,
query=query,
bank_profile=profile,
- lookup_fn=lookup_fn,
+ search_reflections_fn=search_reflections_fn,
+ search_mental_models_fn=search_mental_models_fn,
recall_fn=recall_fn,
- learn_fn=learn_fn,
expand_fn=expand_fn,
context=context,
max_iterations=max_iterations,
@@ -3598,11 +3552,10 @@ class MemoryEngine(MemoryEngineInterface):
)
)
- # Extract mental models from lookup tool outputs - only include models the agent actually used
- # agent_result.used_model_ids contains validated IDs from the done action
- used_model_ids_set = set(agent_result.used_model_ids) if agent_result.used_model_ids else set()
- based_on["mental_model"] = []
- mental_models_result: list[MentalModelRef] = []
+ # Extract mental models from tool outputs - only include models the agent actually used
+ # agent_result.used_mental_model_ids contains validated IDs from the done action
+ used_model_ids_set = set(agent_result.used_mental_model_ids) if agent_result.used_mental_model_ids else set()
+ based_on["mental-models"] = []
seen_model_ids: set[str] = set()
for tc in agent_result.tool_trace:
if tc.tool == "get_mental_model":
@@ -3615,32 +3568,69 @@ class MemoryEngine(MemoryEngineInterface):
if used_model_ids_set and model_id not in used_model_ids_set:
continue # Skip models not actually used by the agent
seen_model_ids.add(model_id)
- # Add to based_on as MemoryFact with type "mental_model"
+ # Add to based_on as MemoryFact with type "mental-models"
model_name = model.get("name", "")
model_summary = model.get("summary") or model.get("description", "")
- based_on["mental_model"].append(
+ based_on["mental-models"].append(
MemoryFact(
id=model_id,
text=f"{model_name}: {model_summary}",
- fact_type="mental_model",
+ fact_type="mental-models",
context=f"{model.get('type', 'concept')} ({model.get('subtype', 'structural')})",
occurred_start=None,
occurred_end=None,
)
)
- mental_models_result.append(
- MentalModelRef(
+ elif tc.tool == "search_mental_models":
+ # Search mental models - include all returned models (filtered by used_model_ids_set if specified)
+ for model in tc.output.get("mental_models", []):
+ model_id = model.get("id")
+ if model_id and model_id not in seen_model_ids:
+ # Only include models that the agent declared as used (or all if none specified)
+ if used_model_ids_set and model_id not in used_model_ids_set:
+ continue # Skip models not actually used by the agent
+ seen_model_ids.add(model_id)
+ # Add to based_on as MemoryFact with type "mental-models"
+ model_name = model.get("name", "")
+ model_summary = model.get("summary") or model.get("description", "")
+ based_on["mental-models"].append(
+ MemoryFact(
id=model_id,
- name=model_name,
- type=model.get("type", "concept"),
- subtype=model.get("subtype", "structural"),
- description=model.get("description", ""),
- summary=model.get("summary"),
+ text=f"{model_name}: {model_summary}",
+ fact_type="mental-models",
+ context=f"{model.get('type', 'concept')} ({model.get('subtype', 'structural')})",
+ occurred_start=None,
+ occurred_end=None,
+ )
+ )
+ elif tc.tool == "search_reflections":
+ # Search reflections - include all returned reflections (filtered by used_reflection_ids_set if specified)
+ used_reflection_ids_set = (
+ set(agent_result.used_reflection_ids) if agent_result.used_reflection_ids else set()
+ )
+ for reflection in tc.output.get("reflections", []):
+ reflection_id = reflection.get("id")
+ if reflection_id and reflection_id not in seen_model_ids:
+ # Only include reflections that the agent declared as used (or all if none specified)
+ if used_reflection_ids_set and reflection_id not in used_reflection_ids_set:
+ continue # Skip reflections not actually used by the agent
+ seen_model_ids.add(reflection_id)
+ # Add to based_on as MemoryFact with type "mental-models" (reflections are synthesized knowledge)
+ reflection_name = reflection.get("name", "")
+ reflection_content = reflection.get("content", "")
+ based_on["mental-models"].append(
+ MemoryFact(
+ id=reflection_id,
+ text=f"{reflection_name}: {reflection_content}",
+ fact_type="mental-models",
+ context="reflection (user-curated)",
+ occurred_start=None,
+ occurred_end=None,
)
)
# List all models lookup - don't add to based_on (too verbose, just a listing)
- # Add directives to mental_models list (they are mental models with subtype='directive')
+ # Add directives to based_on["mental-models"] (they are mental models with subtype='directive')
for directive in directives:
# Extract summary from observations
summary_parts: list[str] = []
@@ -3661,14 +3651,16 @@ class MemoryEngine(MemoryEngineInterface):
if not summary_parts and directive.get("description"):
summary_parts.append(directive["description"])
- mental_models_result.append(
- MentalModelRef(
+ directive_name = directive.get("name", "")
+ directive_summary = "; ".join(summary_parts) if summary_parts else ""
+ based_on["mental-models"].append(
+ MemoryFact(
id=directive.get("id", ""),
- name=directive.get("name", ""),
- type="directive",
- subtype="directive",
- description=directive.get("description", ""),
- summary="; ".join(summary_parts) if summary_parts else None,
+ text=f"{directive_name}: {directive_summary}",
+ fact_type="mental-models",
+ context="directive (directive)",
+ occurred_start=None,
+ occurred_end=None,
)
)
@@ -3688,7 +3680,6 @@ class MemoryEngine(MemoryEngineInterface):
usage=None, # Token tracking not yet implemented for agentic loop
tool_trace=tool_trace_result,
llm_trace=llm_trace_result,
- mental_models=mental_models_result,
directives_applied=directives_applied_result,
)
@@ -4078,955 +4069,6 @@ class MemoryEngine(MemoryEngineInterface):
"observations": observations,
}
- # =========================================================================
- # Mental Models
- # =========================================================================
-
- async def list_mental_models(
- self,
- bank_id: str,
- *,
- subtype: str | None = None,
- tags: list[str] | None = None,
- tags_match: TagsMatch = "any",
- request_context: "RequestContext",
- ) -> list[dict[str, Any]]:
- """List mental models for a bank, optionally filtered by subtype or tags.
-
- Args:
- bank_id: Bank identifier
- subtype: Filter by subtype (structural, emergent, pinned)
- tags: Filter by tags - returns models that match according to tags_match
- tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- query = f"""
- SELECT id, bank_id, subtype, name, description, observations,
- version, entity_id, links, tags, last_updated, created_at
- FROM {fq_table("mental_models")}
- WHERE bank_id = $1
- """
- params: list[Any] = [bank_id]
-
- if subtype:
- query += f" AND subtype = ${len(params) + 1}"
- params.append(subtype)
- # Note: Directives are included in API listing for admin visibility.
- # They are excluded from the reflect agent's tool_lookup (in tools.py) since they're in the system prompt.
-
- # Tags filtering: include untagged models OR models with matching tags
- if tags:
- if tags_match == "any":
- # OR match: model has no tags OR model has at least one matching tag
- query += f" AND (tags = '{{}}' OR tags && ${len(params) + 1})"
- elif tags_match == "all":
- # AND match: model has no tags OR model has all specified tags
- query += f" AND (tags = '{{}}' OR tags @> ${len(params) + 1})"
- elif tags_match == "any_strict":
- # OR match, strict: model must have at least one matching tag (no untagged)
- query += f" AND tags && ${len(params) + 1}"
- elif tags_match == "all_strict":
- # AND match, strict: model must have all specified tags (no untagged)
- query += f" AND tags @> ${len(params) + 1}"
- else: # exact
- # Exact match: model has no tags OR model has exactly the specified tags
- query += f" AND (tags = '{{}}' OR tags = ${len(params) + 1})"
- params.append(tags)
-
- query += " ORDER BY created_at ASC"
-
- async with acquire_with_retry(pool) as conn:
- rows = await conn.fetch(query, *params)
-
- return [self._row_to_mental_model(row) for row in rows]
-
- async def get_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> dict[str, Any] | None:
- """Get a mental model by ID."""
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- row = await conn.fetchrow(
- f"""
- SELECT id, bank_id, subtype, name, description, observations,
- version, entity_id, links, tags, last_updated, created_at
- FROM {fq_table("mental_models")}
- WHERE bank_id = $1 AND id = $2
- """,
- bank_id,
- model_id,
- )
-
- return self._row_to_mental_model(row) if row else None
-
- async def refresh_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- _return_agent_result: bool = False,
- ) -> dict[str, Any] | tuple[dict[str, Any] | None, Any] | None:
- """Refresh the observations for a mental model using the 4-phase reflect loop.
-
- The 4-phase loop:
- 1. SEED: Get diverse memory sample, generate candidate observations
- 2. EVIDENCE HUNT: For each candidate, search for supporting/contradicting evidence
- 3. VALIDATE: Keep/discard/merge candidates based on evidence, extract quotes
- 4. COMPARE: Merge new observations with existing mental model
-
- Uses the model's stored tags to filter recall results.
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID
- request_context: Request context for authentication
- _return_agent_result: Internal flag to return (model, agent_result) tuple for logging
-
- Returns:
- Updated mental model dict, or (model, agent_result) tuple if _return_agent_result=True
- """
- await self._authenticate_tenant(request_context)
-
- # Validate operation if validator is configured
- if self._operation_validator:
- from hindsight_api.extensions.operation_validator import RefreshMentalModelContext
-
- ctx = RefreshMentalModelContext(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- await self._validate_operation(self._operation_validator.validate_refresh_mental_model(ctx))
-
- pool = await self._get_pool()
- start_time = time.time()
-
- # Get the mental model
- model = await self.get_mental_model(bank_id, model_id, request_context=request_context)
- if not model:
- return None
-
- # Don't refresh directives - their observations are user-provided and static
- if model.get("subtype") == "directive":
- logger.info(f"[MENTAL_MODELS] Skipping refresh for directive '{model_id}' - observations are static")
- if _return_agent_result:
- return (model, None)
- return model
-
- # Import refresh state functions and typed models
- from .reflect.mental_model_reflect import (
- BankProfile,
- DirectiveMentalModel,
- check_needs_refresh,
- compute_refresh_state,
- )
-
- # Check if refresh is actually needed by comparing state hashes
- # Get current state inputs
- total_memories = await self._count_memories_since(bank_id, None, pool)
- bank_profile_dict = await self.get_bank_profile(bank_id, request_context=request_context)
- directives_dicts = await self.list_mental_models(bank_id, subtype="directive", request_context=request_context)
-
- # Convert to typed models at the boundary
- bank_profile = BankProfile.model_validate(bank_profile_dict)
- directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts]
-
- # Get stored refresh_state from the model
- stored_refresh_state = model.get("refresh_state")
-
- # Check if refresh is needed
- refresh_check = check_needs_refresh(
- stored_state=stored_refresh_state,
- current_memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- if not refresh_check.needs_refresh:
- logger.info(f"[MENTAL_MODELS] Skipping refresh for '{model_id}' - nothing changed since last refresh")
- if _return_agent_result:
- return (model, None)
- return model
-
- logger.info(
- f"[MENTAL_MODELS] Refresh needed for '{model_id}': {', '.join(refresh_check.reasons)} "
- f"(memories: {total_memories})"
- )
-
- # Use the model's stored tags for filtering recall
- model_tags = model.get("tags") or None
- current_version = model.get("version", 0)
-
- # Import the 4-phase mental model reflect
- from .reflect.mental_model_reflect import run_mental_model_reflect
- from .reflect.tools import tool_recall
-
- metrics = get_metrics_collector()
-
- # Get existing observations (convert Observation models to dicts for the reflect loop)
- from .reflect.observations import Observation
-
- raw_observations = model.get("observations", [])
- existing_observations = [obs.model_dump() if isinstance(obs, Observation) else obs for obs in raw_observations]
-
- # Create callback for getting diverse memories
- async def get_diverse_memories() -> list[dict]:
- """Get a diverse sample of memories for seeding observations."""
- # Get recent memories (last 30 days)
- recent_result = await tool_recall(
- self,
- bank_id,
- "recent activity and events",
- request_context,
- max_tokens=4096,
- tags=model_tags,
- tags_match="any" if model_tags else None,
- )
- recent_memories = recent_result.get("memories", [])
-
- # Get memories related to the mental model topic
- topic_result = await tool_recall(
- self,
- bank_id,
- model.get("name", "") + " " + model.get("description", ""),
- request_context,
- max_tokens=4096,
- tags=model_tags,
- tags_match="any" if model_tags else None,
- )
- topic_memories = topic_result.get("memories", [])
-
- # Combine and deduplicate
- seen_ids = set()
- diverse_memories = []
- for mem in recent_memories + topic_memories:
- mem_id = mem.get("id")
- if mem_id and mem_id not in seen_ids:
- seen_ids.add(mem_id)
- diverse_memories.append(mem)
-
- return diverse_memories
-
- # Create callback for recall
- async def recall_fn(query: str, max_tokens: int) -> dict:
- return await tool_recall(
- self,
- bank_id,
- query,
- request_context,
- max_tokens=max_tokens,
- tags=model_tags,
- tags_match="any" if model_tags else None,
- )
-
- with metrics.record_operation("mental_model_refresh_4phase", bank_id=bank_id, source="api"):
- result = await run_mental_model_reflect(
- llm_config=self._reflect_llm_config,
- bank_id=bank_id,
- mental_model_id=model_id,
- mental_model_name=model.get("name", ""),
- existing_observations=existing_observations,
- current_version=current_version,
- get_diverse_memories_fn=get_diverse_memories,
- recall_fn=recall_fn,
- topic=model.get("description"),
- )
-
- # Update the model with the new observations
- import json
-
- # Convert observations to serializable format
- observations_list = [
- {
- "title": obs.title,
- "content": obs.content,
- "evidence": [
- {
- "memory_id": ev.memory_id,
- "quote": ev.quote,
- "relevance": ev.relevance,
- "timestamp": ev.timestamp.isoformat(),
- }
- for ev in obs.evidence
- ],
- "created_at": obs.created_at.isoformat(),
- }
- for obs in result.observations
- ]
-
- # Compute refresh_state snapshot (using values fetched at start of refresh)
- refresh_state = compute_refresh_state(
- memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- observations_json = {
- "observations": observations_list,
- "version": result.version,
- "last_refresh_at": refresh_state.last_refresh_at,
- "refresh_state": refresh_state.model_dump(),
- }
-
- async with acquire_with_retry(pool) as conn:
- # Save the new version first
- await self.save_mental_model_version(
- conn,
- bank_id,
- model_id,
- observations_list,
- result.version,
- )
-
- # Update the mental model with new observations and version
- updated_row = await conn.fetchrow(
- f"""
- UPDATE {fq_table("mental_models")}
- SET observations = $1::jsonb, version = $2, last_updated = NOW()
- WHERE bank_id = $3 AND id = $4
- RETURNING id, bank_id, subtype, name, description, observations,
- version, entity_id, links, tags, last_updated, created_at
- """,
- json.dumps(observations_json),
- result.version,
- bank_id,
- model_id,
- )
-
- model_result = self._row_to_mental_model(updated_row) if updated_row else None
-
- # Call post-operation hook if validator is configured
- if self._operation_validator:
- from hindsight_api.extensions.operation_validator import RefreshMentalModelResult
-
- duration_ms = int((time.time() - start_time) * 1000)
- result_ctx = RefreshMentalModelResult(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- model_name=model.get("name"),
- observations_count=len(result.observations),
- input_tokens=result.input_tokens,
- output_tokens=result.output_tokens,
- total_tokens=result.total_tokens,
- duration_ms=duration_ms,
- success=True,
- error=None,
- )
- try:
- await self._operation_validator.on_refresh_mental_model_complete(result_ctx)
- except Exception as e:
- logger.warning(f"Post-refresh-mental-model hook error (non-fatal): {e}")
-
- if _return_agent_result:
- return (model_result, result)
- return model_result
-
- async def refresh_mental_model_async(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> dict[str, Any]:
- """
- Submit a background job to refresh a specific mental model.
-
- This is useful for:
- - Refreshing content for newly created learned models
- - Refreshing content for pinned models after description changes
- - Manual refresh of a specific model without touching others
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID to refresh
-
- Returns:
- Dict with operation_id to track progress
- """
- await self._authenticate_tenant(request_context)
-
- # Verify the model exists
- model = await self.get_mental_model(bank_id, model_id, request_context=request_context)
- if not model:
- raise ValueError(f"Mental model '{model_id}' not found in bank '{bank_id}'")
-
- pool = await self._get_pool()
-
- import json
-
- operation_id = uuid.uuid4()
-
- # Insert operation record into database
- async with acquire_with_retry(pool) as conn:
- await conn.execute(
- f"""
- INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata)
- VALUES ($1, $2, $3, $4)
- """,
- operation_id,
- bank_id,
- "refresh_mental_model",
- json.dumps({"model_id": model_id}),
- )
-
- # Submit task to background queue
- task_payload = {
- "type": "refresh_mental_model",
- "operation_id": str(operation_id),
- "bank_id": bank_id,
- "model_id": model_id,
- }
-
- await self._task_backend.submit_task(task_payload)
-
- logger.info(
- f"[MENTAL_MODEL] Refresh task queued for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}"
- )
-
- return {
- "operation_id": str(operation_id),
- "model_id": model_id,
- "status": "queued",
- }
-
- async def refresh_mental_models(
- self,
- bank_id: str,
- *,
- tags: list[str] | None = None,
- subtype: str | None = None,
- request_context: "RequestContext",
- ) -> dict[str, Any]:
- """
- Submit a background job to refresh mental models for a bank.
-
- The background job will (depending on subtype filter):
- 1. Derive structural models from the bank's mission (if subtype is None or "structural")
- 2. Detect emergent candidates (entities worth promoting) (if subtype is None or "emergent")
- 3. Filter candidates by mission relevance
- 4. Create/update mental models with specified tags
- 5. Generate summaries for refreshed mental models
-
- Args:
- bank_id: Bank identifier
- tags: Tags to apply to newly created mental models
- subtype: Only refresh models of this subtype ("structural" or "emergent").
- If None, refreshes all subtypes.
-
- Raises:
- ValueError: If no mission is set for the bank
-
- Returns:
- Dict with operation_id to track progress
- """
- await self._authenticate_tenant(request_context)
-
- # Check that mission is set before scheduling the task
- profile = await self.get_bank_profile(bank_id, request_context=request_context)
- mission = profile.get("mission") or ""
- if not mission:
- raise ValueError(
- f"Cannot refresh mental models: no mission is set for bank '{bank_id}'. Set a mission first."
- )
-
- pool = await self._get_pool()
-
- import json
-
- operation_id = uuid.uuid4()
-
- # Insert operation record into database
- async with acquire_with_retry(pool) as conn:
- await conn.execute(
- f"""
- INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata)
- VALUES ($1, $2, $3, $4)
- """,
- operation_id,
- bank_id,
- "refresh_mental_models",
- json.dumps({}),
- )
-
- # Submit task to background queue
- task_payload = {
- "type": "refresh_mental_models",
- "operation_id": str(operation_id),
- "bank_id": bank_id,
- }
- if tags:
- task_payload["tags"] = tags
- if subtype:
- task_payload["subtype"] = subtype
-
- await self._task_backend.submit_task(task_payload)
-
- logger.info(f"[MENTAL_MODELS] Refresh task queued for bank_id={bank_id}, operation_id={operation_id}")
-
- return {
- "operation_id": str(operation_id),
- "status": "queued",
- }
-
- async def _derive_structural_models_internal(
- self,
- bank_id: str,
- mission: str,
- pool,
- existing_models: list[dict[str, Any]] | None = None,
- tags: list[str] | None = None,
- ) -> list[str]:
- """
- Internal method to derive structural models without auth check.
-
- Args:
- bank_id: Bank identifier
- mission: The bank's mission
- pool: Database connection pool
- existing_models: Optional list of existing structural models
- tags: Tags to apply to created mental models
-
- Returns:
- List of model IDs to remove (existing models not in LLM output)
- """
- from .mental_models.models import MentalModelSubtype
- from .mental_models.structural import derive_structural_models
-
- templates, models_to_remove = await derive_structural_models(
- self._llm_config, mission, existing_models=existing_models
- )
-
- model_tags = tags or []
- created_count = 0
- async with acquire_with_retry(pool) as conn:
- for template in templates:
- try:
- await conn.fetchrow(
- f"""
- INSERT INTO {fq_table("mental_models")}
- (id, bank_id, subtype, name, description, tags)
- VALUES ($1, $2, $3, $4, $5, $6)
- ON CONFLICT (id, bank_id) DO UPDATE SET
- name = EXCLUDED.name,
- description = EXCLUDED.description,
- tags = EXCLUDED.tags
- RETURNING id
- """,
- template.id,
- bank_id,
- MentalModelSubtype.STRUCTURAL.value,
- template.name,
- template.description,
- model_tags,
- )
- created_count += 1
- except Exception as e:
- logger.warning(f"[MENTAL_MODELS] Failed to create structural model {template.id}: {e}")
-
- logger.info(f"[MENTAL_MODELS] Created/updated {created_count} structural models for bank {bank_id}")
- return models_to_remove
-
- async def _promote_entity_internal(
- self, bank_id: str, entity_id: str, pool, tags: list[str] | None = None
- ) -> dict[str, Any] | None:
- """Internal method to promote entity to mental model without auth check.
-
- Args:
- bank_id: Bank identifier
- entity_id: Entity ID to promote
- pool: Database connection pool
- tags: Tags to apply to the created mental model
- """
- from .mental_models.models import MentalModelSubtype
-
- async with acquire_with_retry(pool) as conn:
- # Get entity info
- entity = await conn.fetchrow(
- f"SELECT id, canonical_name FROM {fq_table('entities')} WHERE id = $1 AND bank_id = $2",
- uuid.UUID(entity_id),
- bank_id,
- )
-
- if not entity:
- return None
-
- # Create mental model from entity
- model_id = f"entity-{entity['canonical_name'].lower().replace(' ', '-')}"
- row = await conn.fetchrow(
- f"""
- INSERT INTO {fq_table("mental_models")}
- (id, bank_id, subtype, name, description, entity_id, tags)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
- ON CONFLICT (id, bank_id) DO NOTHING
- RETURNING id, bank_id, subtype, name, description, observations,
- entity_id, links, tags, last_updated, created_at
- """,
- model_id,
- bank_id,
- MentalModelSubtype.EMERGENT.value,
- entity["canonical_name"],
- f"Mental model for {entity['canonical_name']}",
- entity["id"],
- tags or [], # Apply tags from refresh operation
- )
-
- return self._row_to_mental_model(row) if row else None
-
- async def create_mental_model(
- self,
- bank_id: str,
- name: str,
- description: str,
- *,
- subtype: str = "pinned",
- observations: list[dict[str, Any]] | None = None,
- tags: list[str] | None = None,
- request_context: "RequestContext",
- ) -> dict[str, Any]:
- """
- Create a mental model.
-
- Supports two subtypes:
- - 'pinned': User-defined topic, observations are LLM-generated on refresh
- - 'directive': User-defined hard rules, observations are provided at creation
-
- For directives, observations must be provided and will NOT be regenerated.
- For pinned models, observations are generated by the reflect agent on refresh.
-
- Args:
- bank_id: Bank identifier
- name: Human-readable name for the mental model
- description: One-liner description for quick scanning
- subtype: 'pinned' (default) or 'directive'
- observations: For directives, list of {title, text} dicts. Ignored for pinned.
- tags: Tags for scoped visibility
-
- Returns:
- The created mental model
- """
- import json
-
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- from .mental_models.models import MentalModelSubtype
-
- # Validate subtype
- if subtype not in ("pinned", "directive"):
- raise ValueError(f"Invalid subtype '{subtype}'. Must be 'pinned' or 'directive'.")
-
- # For directives, observations must be provided
- if subtype == "directive":
- if not observations:
- raise ValueError("Directives require observations to be provided")
- subtype_enum = MentalModelSubtype.DIRECTIVE
- model_id = f"directive-{name.lower().replace(' ', '-').replace('/', '-')}"
- # Format observations for storage
- observations_json = json.dumps({"observations": observations})
- else:
- subtype_enum = MentalModelSubtype.PINNED
- model_id = f"pinned-{name.lower().replace(' ', '-').replace('/', '-')}"
- observations_json = None
-
- async with acquire_with_retry(pool) as conn:
- # Check if model already exists
- existing = await conn.fetchrow(
- f"SELECT id FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
- bank_id,
- model_id,
- )
- if existing:
- raise ValueError(f"Mental model with name '{name}' already exists")
-
- row = await conn.fetchrow(
- f"""
- INSERT INTO {fq_table("mental_models")}
- (id, bank_id, subtype, name, description, observations, tags, last_updated)
- VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
- RETURNING id, bank_id, subtype, name, description, observations,
- entity_id, links, tags, last_updated, created_at
- """,
- model_id,
- bank_id,
- subtype_enum.value,
- name,
- description,
- observations_json,
- tags or [],
- datetime.now(UTC) if subtype == "directive" else None,
- )
-
- logger.info(f"[MENTAL_MODELS] Created {subtype} mental model '{name}' (id={model_id}) for bank {bank_id}")
- return self._row_to_mental_model(row)
-
- async def delete_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> bool:
- """Delete a mental model."""
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- result = await conn.execute(
- f"DELETE FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
- bank_id,
- model_id,
- )
-
- return result == "DELETE 1"
-
- async def update_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- name: str | None = None,
- description: str | None = None,
- request_context: "RequestContext",
- ) -> dict | None:
- """Update a mental model's name and/or description.
-
- Returns the updated mental model dict, or None if not found.
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- # Build dynamic update query
- updates = []
- params = [bank_id, model_id]
- param_idx = 3
-
- if name is not None:
- updates.append(f"name = ${param_idx}")
- params.append(name)
- param_idx += 1
-
- if description is not None:
- updates.append(f"description = ${param_idx}")
- params.append(description)
- param_idx += 1
-
- if not updates:
- return None
-
- query = f"""
- UPDATE {fq_table("mental_models")}
- SET {", ".join(updates)}
- WHERE bank_id = $1 AND id = $2
- RETURNING id, bank_id, subtype, name, description, observations, version, entity_id, links, tags, last_updated, created_at
- """
-
- row = await conn.fetchrow(query, *params)
-
- if not row:
- return None
-
- return self._row_to_mental_model(row)
-
- async def save_mental_model_version(
- self,
- conn,
- bank_id: str,
- model_id: str,
- observations: list[dict],
- new_version: int,
- ) -> None:
- """Save a new version of mental model observations.
-
- Args:
- conn: Database connection
- bank_id: Bank identifier
- model_id: Mental model ID
- observations: List of observation dicts
- new_version: Version number to save
- """
- import json
-
- from ..config import get_config
-
- config = get_config()
- max_versions = getattr(config, "mental_model_max_versions", 10)
-
- # Save the new version
- await conn.execute(
- f"""
- INSERT INTO {fq_table("mental_model_versions")}
- (mental_model_id, bank_id, version, observations)
- VALUES ($1, $2, $3, $4::jsonb)
- ON CONFLICT (mental_model_id, bank_id, version) DO UPDATE
- SET observations = EXCLUDED.observations, created_at = NOW()
- """,
- model_id,
- bank_id,
- new_version,
- json.dumps({"observations": observations}),
- )
-
- # Clean up old versions (keep only max_versions)
- await conn.execute(
- f"""
- DELETE FROM {fq_table("mental_model_versions")}
- WHERE mental_model_id = $1 AND bank_id = $2 AND version <= $3::int - $4::int
- """,
- model_id,
- bank_id,
- new_version,
- max_versions,
- )
-
- async def get_mental_model_versions(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> list[dict]:
- """List version history for a mental model.
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID
-
- Returns:
- List of version summaries sorted by version descending
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- rows = await conn.fetch(
- f"""
- SELECT version, created_at,
- jsonb_array_length(observations->'observations') as observation_count
- FROM {fq_table("mental_model_versions")}
- WHERE mental_model_id = $1 AND bank_id = $2
- ORDER BY version DESC
- """,
- model_id,
- bank_id,
- )
-
- return [
- {
- "version": row["version"],
- "created_at": row["created_at"].isoformat() if row["created_at"] else None,
- "observation_count": row["observation_count"] or 0,
- }
- for row in rows
- ]
-
- async def get_mental_model_version(
- self,
- bank_id: str,
- model_id: str,
- version: int,
- *,
- request_context: "RequestContext",
- ) -> dict | None:
- """Get a specific version of mental model observations.
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID
- version: Version number to retrieve
-
- Returns:
- Version data with observations, or None if not found
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- row = await conn.fetchrow(
- f"""
- SELECT version, observations, created_at
- FROM {fq_table("mental_model_versions")}
- WHERE mental_model_id = $1 AND bank_id = $2 AND version = $3
- """,
- model_id,
- bank_id,
- version,
- )
-
- if not row:
- return None
-
- import json
-
- observations_data = row["observations"]
- if isinstance(observations_data, str):
- observations_data = json.loads(observations_data)
-
- observations = observations_data.get("observations", []) if isinstance(observations_data, dict) else []
-
- return {
- "version": row["version"],
- "observations": self._parse_observations(observations),
- "created_at": row["created_at"].isoformat() if row["created_at"] else None,
- }
-
- def _row_to_mental_model(self, row) -> dict[str, Any]:
- """Convert a database row to a mental model dict."""
- import json
-
- # Parse observations JSON - can be a dict {"observations": [...]} or a list []
- observations_data = row.get("observations")
- last_refresh_at = None
- refresh_state = None
- if observations_data is None:
- observations_raw = []
- elif isinstance(observations_data, str):
- observations_data = json.loads(observations_data)
- observations_raw = (
- observations_data.get("observations", []) if isinstance(observations_data, dict) else observations_data
- )
- if isinstance(observations_data, dict):
- last_refresh_at = observations_data.get("last_refresh_at")
- refresh_state = observations_data.get("refresh_state")
- elif isinstance(observations_data, list):
- observations_raw = observations_data
- elif isinstance(observations_data, dict):
- observations_raw = observations_data.get("observations", [])
- last_refresh_at = observations_data.get("last_refresh_at")
- refresh_state = observations_data.get("refresh_state")
- else:
- observations_raw = []
-
- # Parse observations into typed models
- observations = self._parse_observations(observations_raw)
-
- return {
- "id": row["id"],
- "bank_id": row["bank_id"],
- "subtype": row["subtype"],
- "name": row["name"],
- "description": row["description"],
- "observations": observations,
- "version": row.get("version", 0),
- "entity_id": str(row["entity_id"]) if row["entity_id"] else None,
- "links": row["links"] or [],
- "tags": list(row["tags"]) if row.get("tags") else [],
- "last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
- "last_refresh_at": last_refresh_at,
- "refresh_state": refresh_state,
- "created_at": row["created_at"].isoformat(),
- }
-
def _parse_observations(self, observations_raw: list):
"""Parse raw observation dicts into typed Observation models.
@@ -5115,10 +4157,10 @@ class MemoryEngine(MemoryEngineInterface):
fact_ids: list[str],
) -> int:
"""
- Remove fact IDs from mental model observations when memories are deleted.
+ Remove fact IDs from mental model source_memory_ids when memories are deleted.
- Uses JSONB path operations to find and update mental models that reference
- the deleted fact IDs in their observations.
+ Mental models are now stored in memory_units with fact_type='mental_model'
+ and have a source_memory_ids column (UUID[]) tracking their source memories.
Args:
conn: Database connection
@@ -5131,45 +4173,29 @@ class MemoryEngine(MemoryEngineInterface):
if not fact_ids:
return 0
- # Convert fact_ids to a jsonb array for efficient comparison
- import json
+ # Convert string IDs to UUIDs for the array comparison
+ import uuid as uuid_module
- fact_ids_json = json.dumps(fact_ids)
+ fact_uuids = [uuid_module.UUID(fid) for fid in fact_ids]
- # Update mental models by removing the deleted fact IDs from all observations
- # This uses jsonb_set to update each observation's fact_ids array
+ # Update mental models (memory_units with fact_type='mental_model')
+ # by removing the deleted fact IDs from source_memory_ids
+ # Use array subtraction: source_memory_ids - deleted_ids
result = await conn.execute(
f"""
- UPDATE {fq_table("mental_models")}
- SET observations = jsonb_set(
- observations,
- '{{observations}}',
- (
- SELECT COALESCE(jsonb_agg(
- jsonb_set(
- observation,
- '{{fact_ids}}',
- (
- SELECT COALESCE(jsonb_agg(fid), '[]'::jsonb)
- FROM jsonb_array_elements_text(observation->'fact_ids') AS fid
- WHERE NOT (fid::text = ANY($2::text[]))
- )
- )
- ), '[]'::jsonb)
- FROM jsonb_array_elements(observations->'observations') AS observation
- )
+ UPDATE {fq_table("memory_units")}
+ SET source_memory_ids = (
+ SELECT COALESCE(array_agg(elem), ARRAY[]::uuid[])
+ FROM unnest(source_memory_ids) AS elem
+ WHERE elem != ALL($2::uuid[])
),
- last_updated = NOW()
+ updated_at = NOW()
WHERE bank_id = $1
- AND EXISTS (
- SELECT 1
- FROM jsonb_array_elements(observations->'observations') AS observation,
- jsonb_array_elements_text(observation->'fact_ids') AS fid
- WHERE fid::text = ANY($2::text[])
- )
+ AND fact_type = 'mental_model'
+ AND source_memory_ids && $2::uuid[]
""",
bank_id,
- fact_ids,
+ fact_uuids,
)
# Parse the result to get number of updated rows
@@ -5180,34 +4206,1113 @@ class MemoryEngine(MemoryEngineInterface):
)
return updated_count
- async def list_operations(
+ # =========================================================================
+ # LEARNINGS CRUD
+ # =========================================================================
+
+ async def list_learnings(
self,
bank_id: str,
*,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ limit: int = 100,
+ offset: int = 0,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
- """List async operations for a bank."""
+ """List learnings for a bank.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of learning dicts
+ """
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
- # Get total count
- total_row = await conn.fetchrow(
- f"SELECT COUNT(*) as total FROM {fq_table('async_operations')} WHERE bank_id = $1",
+ # Build tag filter
+ tag_filter = ""
+ params: list[Any] = [bank_id, limit, offset]
+ if tags:
+ if tags_match == "all":
+ tag_filter = " AND tags @> $4::varchar[]"
+ elif tags_match == "exact":
+ tag_filter = " AND tags = $4::varchar[]"
+ else: # any
+ tag_filter = " AND tags && $4::varchar[]"
+ params.append(tags)
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ FROM {fq_table("learnings")}
+ WHERE bank_id = $1 {tag_filter}
+ ORDER BY proof_count DESC, updated_at DESC
+ LIMIT $2 OFFSET $3
+ """,
+ *params,
+ )
+
+ return [self._row_to_learning(row) for row in rows]
+
+ async def get_learning(
+ self,
+ bank_id: str,
+ learning_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single learning by ID.
+
+ Args:
+ bank_id: Bank identifier
+ learning_id: Learning UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Learning dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ FROM {fq_table("learnings")}
+ WHERE bank_id = $1 AND id = $2
+ """,
bank_id,
+ learning_id,
+ )
+
+ return self._row_to_learning(row) if row else None
+
+ async def create_learning(
+ self,
+ bank_id: str,
+ text: str,
+ *,
+ proof_count: int = 1,
+ tags: list[str] | None = None,
+ mission_context: str | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Create a new learning.
+
+ Args:
+ bank_id: Bank identifier
+ text: The learning text
+ proof_count: Initial proof count (default 1)
+ tags: Optional tags for scoped visibility
+ mission_context: Hash of mission when created
+ request_context: Request context for authentication
+
+ Returns:
+ The created learning dict
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ # Generate embedding for the learning text
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
+ # Convert embedding to string for asyncpg vector type
+ embedding_str = str(embedding[0]) if embedding else None
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("learnings")}
+ (bank_id, text, proof_count, mission_context, embedding, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ """,
+ bank_id,
+ text,
+ proof_count,
+ mission_context,
+ embedding_str,
+ tags or [],
+ )
+
+ logger.info(f"[LEARNINGS] Created learning for bank {bank_id}: {text[:50]}...")
+ return self._row_to_learning(row)
+
+ async def update_learning(
+ self,
+ bank_id: str,
+ learning_id: str,
+ *,
+ text: str | None = None,
+ increment_proof: bool = False,
+ add_history: dict[str, Any] | None = None,
+ mark_pre_mission_change: bool = False,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Update a learning.
+
+ Args:
+ bank_id: Bank identifier
+ learning_id: Learning UUID
+ text: New text (if changing)
+ increment_proof: Whether to increment proof_count
+ add_history: History entry to append (for contradictions)
+ mark_pre_mission_change: Whether to mark as pre-mission-change
+ request_context: Request context for authentication
+
+ Returns:
+ Updated learning dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build dynamic update
+ updates = ["updated_at = NOW()"]
+ params: list[Any] = [bank_id, learning_id]
+ param_idx = 3
+
+ if text is not None:
+ updates.append(f"text = ${param_idx}")
+ params.append(text)
+ param_idx += 1
+ # Also update embedding (convert to string for asyncpg vector type)
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
+ if embedding:
+ updates.append(f"embedding = ${param_idx}")
+ params.append(str(embedding[0]))
+ param_idx += 1
+
+ if increment_proof:
+ updates.append("proof_count = proof_count + 1")
+
+ if add_history:
+ import json
+
+ updates.append(f"history = history || ${param_idx}::jsonb")
+ params.append(json.dumps([add_history]))
+ param_idx += 1
+
+ if mark_pre_mission_change:
+ updates.append("pre_mission_change = TRUE")
+
+ query = f"""
+ UPDATE {fq_table("learnings")}
+ SET {", ".join(updates)}
+ WHERE bank_id = $1 AND id = $2
+ RETURNING id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ """
+
+ row = await conn.fetchrow(query, *params)
+
+ return self._row_to_learning(row) if row else None
+
+ async def delete_learning(
+ self,
+ bank_id: str,
+ learning_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> bool:
+ """Delete a learning.
+
+ Args:
+ bank_id: Bank identifier
+ learning_id: Learning UUID
+ request_context: Request context for authentication
+
+ Returns:
+ True if deleted, False if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"DELETE FROM {fq_table('learnings')} WHERE bank_id = $1 AND id = $2",
+ bank_id,
+ learning_id,
+ )
+
+ return result == "DELETE 1"
+
+ def _row_to_learning(self, row) -> dict[str, Any]:
+ """Convert a database row to a learning dict."""
+ import json
+
+ # Parse history - asyncpg may return JSONB as string in some cases
+ history = row["history"]
+ if isinstance(history, str):
+ history = json.loads(history)
+ elif history is None:
+ history = []
+
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "text": row["text"],
+ "proof_count": row["proof_count"],
+ "history": history,
+ "mission_context": row["mission_context"],
+ "pre_mission_change": row["pre_mission_change"],
+ "tags": row["tags"] or [],
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ }
+
+ async def mark_learnings_pre_mission_change(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> int:
+ """Mark all learnings as pre-mission-change when mission changes.
+
+ Args:
+ bank_id: Bank identifier
+ request_context: Request context for authentication
+
+ Returns:
+ Number of learnings marked
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"""
+ UPDATE {fq_table("learnings")}
+ SET pre_mission_change = TRUE, updated_at = NOW()
+ WHERE bank_id = $1 AND pre_mission_change = FALSE
+ """,
+ bank_id,
+ )
+
+ # Also update bank's mission_changed_at
+ await conn.execute(
+ f"""
+ UPDATE {fq_table("banks")}
+ SET mission_changed_at = NOW()
+ WHERE bank_id = $1
+ """,
+ bank_id,
+ )
+
+ count = int(result.split()[-1]) if result and "UPDATE" in result else 0
+ if count > 0:
+ logger.info(f"[LEARNINGS] Marked {count} learnings as pre-mission-change for bank {bank_id}")
+ return count
+
+ # =========================================================================
+ # MENTAL MODELS (CONSOLIDATED) - Read-only access to auto-consolidated mental models
+ # =========================================================================
+
+ async def list_mental_models_consolidated(
+ self,
+ bank_id: str,
+ *,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ limit: int = 100,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> list[dict[str, Any]]:
+ """List auto-consolidated mental models for a bank.
+
+ Mental models are stored in memory_units with fact_type='mental_model'.
+ They are automatically created and updated by the consolidation engine.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of mental model dicts
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build tag filter
+ tag_filter = ""
+ params: list[Any] = [bank_id, limit, offset]
+ if tags:
+ if tags_match == "all":
+ tag_filter = " AND tags @> $4::varchar[]"
+ elif tags_match == "exact":
+ tag_filter = " AND tags = $4::varchar[]"
+ else: # any
+ tag_filter = " AND tags && $4::varchar[]"
+ params.append(tags)
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type = 'mental_model' {tag_filter}
+ ORDER BY updated_at DESC NULLS LAST
+ LIMIT $2 OFFSET $3
+ """,
+ *params,
+ )
+
+ return [self._row_to_mental_model_consolidated(row) for row in rows]
+
+ async def get_mental_model_consolidated(
+ self,
+ bank_id: str,
+ model_id: str,
+ *,
+ include_source_memories: bool = True,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single mental model by ID.
+
+ Args:
+ bank_id: Bank identifier
+ model_id: Mental model ID
+ include_source_memories: Whether to include full source memory details
+ request_context: Request context for authentication
+
+ Returns:
+ Mental model dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND id = $2 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ model_id,
+ )
+
+ if not row:
+ return None
+
+ result = self._row_to_mental_model_consolidated(row)
+
+ # Fetch source memories if requested and source_memory_ids exist
+ if include_source_memories and result.get("source_memory_ids"):
+ source_ids = [uuid.UUID(sid) if isinstance(sid, str) else sid for sid in result["source_memory_ids"]]
+ source_rows = await conn.fetch(
+ f"""
+ SELECT id, text, fact_type, context, occurred_start, mentioned_at
+ FROM {fq_table("memory_units")}
+ WHERE id = ANY($1::uuid[])
+ ORDER BY mentioned_at DESC NULLS LAST
+ """,
+ source_ids,
+ )
+ result["source_memories"] = [
+ {
+ "id": str(r["id"]),
+ "text": r["text"],
+ "type": r["fact_type"],
+ "context": r["context"],
+ "occurred_start": r["occurred_start"].isoformat() if r["occurred_start"] else None,
+ "mentioned_at": r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
+ }
+ for r in source_rows
+ ]
+
+ return result
+
+ def _row_to_mental_model_consolidated(self, row: Any) -> dict[str, Any]:
+ """Convert a database row to a mental model dict."""
+ import json
+
+ history = row["history"]
+ if isinstance(history, str):
+ history = json.loads(history)
+ elif history is None:
+ history = []
+
+ # Convert source_memory_ids to strings
+ source_memory_ids = row.get("source_memory_ids") or []
+ source_memory_ids = [str(sid) for sid in source_memory_ids]
+
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "text": row["text"],
+ "proof_count": row["proof_count"] or 1,
+ "history": history,
+ "tags": row["tags"] or [],
+ "source_memory_ids": source_memory_ids,
+ "source_memories": [], # Populated separately when fetching full details
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ }
+
+ # =========================================================================
+ # REFLECTIONS CRUD
+ # =========================================================================
+
+ async def list_reflections(
+ self,
+ bank_id: str,
+ *,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ limit: int = 100,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> list[dict[str, Any]]:
+ """List pinned reflections for a bank.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of pinned reflection dicts
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build tag filter
+ tag_filter = ""
+ params: list[Any] = [bank_id, limit, offset]
+ if tags:
+ if tags_match == "all":
+ tag_filter = " AND tags @> $4::varchar[]"
+ elif tags_match == "exact":
+ tag_filter = " AND tags = $4::varchar[]"
+ else: # any
+ tag_filter = " AND tags && $4::varchar[]"
+ params.append(tags)
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at, reflect_response
+ FROM {fq_table("reflections")}
+ WHERE bank_id = $1 {tag_filter}
+ ORDER BY last_refreshed_at DESC
+ LIMIT $2 OFFSET $3
+ """,
+ *params,
+ )
+
+ return [self._row_to_reflection(row) for row in rows]
+
+ async def get_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single pinned reflection by ID.
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Pinned reflection dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at, reflect_response
+ FROM {fq_table("reflections")}
+ WHERE bank_id = $1 AND id = $2
+ """,
+ bank_id,
+ reflection_id,
+ )
+
+ return self._row_to_reflection(row) if row else None
+
+ async def create_reflection(
+ self,
+ bank_id: str,
+ name: str,
+ source_query: str,
+ content: str,
+ *,
+ tags: list[str] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Create a new pinned reflection.
+
+ Args:
+ bank_id: Bank identifier
+ name: Human-readable name for the reflection
+ source_query: The query that generated this reflection
+ content: The synthesized content
+ tags: Optional tags for scoped visibility
+ request_context: Request context for authentication
+
+ Returns:
+ The created pinned reflection dict
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ # Generate embedding for the content
+ embedding_text = f"{name} {content}"
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
+ # Convert embedding to string for asyncpg vector type
+ embedding_str = str(embedding[0]) if embedding else None
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("reflections")}
+ (bank_id, name, source_query, content, embedding, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at
+ """,
+ bank_id,
+ name,
+ source_query,
+ content,
+ embedding_str,
+ tags or [],
+ )
+
+ logger.info(f"[REFLECTIONS] Created pinned reflection '{name}' for bank {bank_id}")
+ return self._row_to_reflection(row)
+
+ async def refresh_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Refresh a pinned reflection by re-running its source query.
+
+ This method:
+ 1. Gets the pinned reflection
+ 2. Runs the source_query through reflect
+ 3. Updates the content with the new synthesis
+ 4. Updates last_refreshed_at
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Updated pinned reflection dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+
+ # Get the current reflection
+ reflection = await self.get_reflection(bank_id, reflection_id, request_context=request_context)
+ if not reflection:
+ return None
+
+ # Run reflect with the source query, excluding the reflection being refreshed
+ reflect_result = await self.reflect_async(
+ bank_id=bank_id,
+ query=reflection["source_query"],
+ request_context=request_context,
+ exclude_reflection_ids=[reflection_id],
+ )
+
+ # Build reflect_response payload to store
+ reflect_response_payload = {
+ "text": reflect_result.text,
+ "based_on": {
+ fact_type: [
+ {
+ "id": str(fact.id),
+ "text": fact.text,
+ "type": fact_type,
+ }
+ for fact in facts
+ ]
+ for fact_type, facts in reflect_result.based_on.items()
+ },
+ "mental_models": [], # Mental models are included in based_on["mental-models"]
+ }
+
+ # Update the reflection with new content and reflect_response
+ return await self.update_reflection(
+ bank_id,
+ reflection_id,
+ content=reflect_result.text,
+ reflect_response=reflect_response_payload,
+ request_context=request_context,
+ )
+
+ async def update_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ name: str | None = None,
+ content: str | None = None,
+ reflect_response: dict[str, Any] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Update a pinned reflection.
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ name: New name (if changing)
+ content: New content (if changing)
+ reflect_response: Full reflect API response payload (if changing)
+ request_context: Request context for authentication
+
+ Returns:
+ Updated pinned reflection dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build dynamic update
+ updates = []
+ params: list[Any] = [bank_id, reflection_id]
+ param_idx = 3
+
+ if name is not None:
+ updates.append(f"name = ${param_idx}")
+ params.append(name)
+ param_idx += 1
+
+ if content is not None:
+ updates.append(f"content = ${param_idx}")
+ params.append(content)
+ param_idx += 1
+ updates.append("last_refreshed_at = NOW()")
+ # 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])
+ if embedding:
+ updates.append(f"embedding = ${param_idx}")
+ params.append(str(embedding[0]))
+ param_idx += 1
+
+ if reflect_response is not None:
+ updates.append(f"reflect_response = ${param_idx}")
+ params.append(json.dumps(reflect_response))
+ param_idx += 1
+
+ if not updates:
+ return None
+
+ query = f"""
+ UPDATE {fq_table("reflections")}
+ SET {", ".join(updates)}
+ WHERE bank_id = $1 AND id = $2
+ RETURNING id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at, reflect_response
+ """
+
+ row = await conn.fetchrow(query, *params)
+
+ return self._row_to_reflection(row) if row else None
+
+ async def delete_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> bool:
+ """Delete a pinned reflection.
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ request_context: Request context for authentication
+
+ Returns:
+ True if deleted, False if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"DELETE FROM {fq_table('reflections')} WHERE bank_id = $1 AND id = $2",
+ bank_id,
+ reflection_id,
+ )
+
+ return result == "DELETE 1"
+
+ def _row_to_reflection(self, row) -> dict[str, Any]:
+ """Convert a database row to a reflection dict."""
+ reflect_response = row.get("reflect_response")
+ # Parse JSON string to dict if needed (asyncpg may return JSONB as string)
+ if isinstance(reflect_response, str):
+ try:
+ reflect_response = json.loads(reflect_response)
+ except json.JSONDecodeError:
+ reflect_response = None
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "name": row["name"],
+ "source_query": row["source_query"],
+ "content": row["content"],
+ "tags": row["tags"] or [],
+ "last_refreshed_at": row["last_refreshed_at"].isoformat() if row["last_refreshed_at"] else None,
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "reflect_response": reflect_response,
+ }
+
+ # =========================================================================
+ # Directives - Hard rules injected into prompts
+ # =========================================================================
+
+ async def list_directives(
+ self,
+ bank_id: str,
+ *,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ active_only: bool = True,
+ limit: int = 100,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> list[dict[str, Any]]:
+ """List directives for a bank.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ active_only: Only return active directives (default True)
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of directive dicts
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build filters
+ filters = ["bank_id = $1"]
+ params: list[Any] = [bank_id]
+ param_idx = 2
+
+ if active_only:
+ filters.append("is_active = TRUE")
+
+ if tags:
+ if tags_match == "all":
+ filters.append(f"tags @> ${param_idx}::varchar[]")
+ elif tags_match == "exact":
+ filters.append(f"tags = ${param_idx}::varchar[]")
+ else: # any
+ filters.append(f"tags && ${param_idx}::varchar[]")
+ params.append(tags)
+ param_idx += 1
+
+ params.extend([limit, offset])
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ FROM {fq_table("directives")}
+ WHERE {" AND ".join(filters)}
+ ORDER BY priority DESC, created_at DESC
+ LIMIT ${param_idx} OFFSET ${param_idx + 1}
+ """,
+ *params,
+ )
+
+ return [self._row_to_directive(row) for row in rows]
+
+ async def get_directive(
+ self,
+ bank_id: str,
+ directive_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single directive by ID.
+
+ Args:
+ bank_id: Bank identifier
+ directive_id: Directive UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Directive dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ FROM {fq_table("directives")}
+ WHERE bank_id = $1 AND id = $2
+ """,
+ bank_id,
+ directive_id,
+ )
+
+ return self._row_to_directive(row) if row else None
+
+ async def create_directive(
+ self,
+ bank_id: str,
+ name: str,
+ content: str,
+ *,
+ priority: int = 0,
+ is_active: bool = True,
+ tags: list[str] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Create a new directive.
+
+ Args:
+ bank_id: Bank identifier
+ name: Human-readable name for the directive
+ content: The directive text to inject into prompts
+ priority: Higher priority directives are injected first (default 0)
+ is_active: Whether this directive is active (default True)
+ tags: Optional tags for filtering
+ request_context: Request context for authentication
+
+ Returns:
+ The created directive dict
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("directives")}
+ (bank_id, name, content, priority, is_active, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ """,
+ bank_id,
+ name,
+ content,
+ priority,
+ is_active,
+ tags or [],
+ )
+
+ logger.info(f"[DIRECTIVES] Created directive '{name}' for bank {bank_id}")
+ return self._row_to_directive(row)
+
+ async def update_directive(
+ self,
+ bank_id: str,
+ directive_id: str,
+ *,
+ name: str | None = None,
+ content: str | None = None,
+ priority: int | None = None,
+ is_active: bool | None = None,
+ tags: list[str] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Update a directive.
+
+ Args:
+ bank_id: Bank identifier
+ directive_id: Directive UUID
+ name: New name (optional)
+ content: New content (optional)
+ priority: New priority (optional)
+ is_active: New active status (optional)
+ tags: New tags (optional)
+ request_context: Request context for authentication
+
+ Returns:
+ Updated directive dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ # Build update query dynamically
+ updates = ["updated_at = now()"]
+ params: list[Any] = []
+ param_idx = 1
+
+ if name is not None:
+ updates.append(f"name = ${param_idx}")
+ params.append(name)
+ param_idx += 1
+
+ if content is not None:
+ updates.append(f"content = ${param_idx}")
+ params.append(content)
+ param_idx += 1
+
+ if priority is not None:
+ updates.append(f"priority = ${param_idx}")
+ params.append(priority)
+ param_idx += 1
+
+ if is_active is not None:
+ updates.append(f"is_active = ${param_idx}")
+ params.append(is_active)
+ param_idx += 1
+
+ if tags is not None:
+ updates.append(f"tags = ${param_idx}")
+ params.append(tags)
+ param_idx += 1
+
+ params.extend([bank_id, directive_id])
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ UPDATE {fq_table("directives")}
+ SET {", ".join(updates)}
+ WHERE bank_id = ${param_idx} AND id = ${param_idx + 1}
+ RETURNING id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ """,
+ *params,
+ )
+
+ return self._row_to_directive(row) if row else None
+
+ async def delete_directive(
+ self,
+ bank_id: str,
+ directive_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> bool:
+ """Delete a directive.
+
+ Args:
+ bank_id: Bank identifier
+ directive_id: Directive UUID
+ request_context: Request context for authentication
+
+ Returns:
+ True if deleted, False if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"DELETE FROM {fq_table('directives')} WHERE bank_id = $1 AND id = $2",
+ bank_id,
+ directive_id,
+ )
+
+ return result == "DELETE 1"
+
+ def _row_to_directive(self, row) -> dict[str, Any]:
+ """Convert a database row to a directive dict."""
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "name": row["name"],
+ "content": row["content"],
+ "priority": row["priority"],
+ "is_active": row["is_active"],
+ "tags": row["tags"] or [],
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ }
+
+ async def list_operations(
+ self,
+ bank_id: str,
+ *,
+ status: str | None = None,
+ limit: int = 20,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """List async operations for a bank with optional filtering and pagination.
+
+ Args:
+ bank_id: Bank identifier
+ status: Optional status filter (pending, completed, failed)
+ limit: Maximum number of operations to return (default 20)
+ offset: Number of operations to skip (default 0)
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with total count and list of operations, sorted by most recent first
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build WHERE clause
+ where_conditions = ["bank_id = $1"]
+ params: list[Any] = [bank_id]
+
+ if status:
+ # Map API status to DB statuses (pending includes processing)
+ if status == "pending":
+ where_conditions.append("status IN ('pending', 'processing')")
+ else:
+ where_conditions.append(f"status = ${len(params) + 1}")
+ params.append(status)
+
+ where_clause = " AND ".join(where_conditions)
+
+ # Get total count (with filter)
+ total_row = await conn.fetchrow(
+ f"SELECT COUNT(*) as total FROM {fq_table('async_operations')} WHERE {where_clause}",
+ *params,
)
total = total_row["total"] if total_row else 0
- # Get recent operations
+ # Get operations with pagination
operations = await conn.fetch(
f"""
SELECT operation_id, operation_type, created_at, status, error_message
FROM {fq_table("async_operations")}
- WHERE bank_id = $1
+ WHERE {where_clause}
ORDER BY created_at DESC
- LIMIT 50
+ LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
""",
- bank_id,
+ *params,
+ limit,
+ offset,
)
return {
@@ -5219,7 +5324,8 @@ class MemoryEngine(MemoryEngineInterface):
"items_count": 0,
"document_id": None,
"created_at": row["created_at"].isoformat(),
- "status": row["status"],
+ # Map DB status to API status (processing -> pending for simplicity)
+ "status": "pending" if row["status"] in ("pending", "processing") else row["status"],
"error_message": row["error_message"],
}
for row in operations
@@ -5353,19 +5459,57 @@ class MemoryEngine(MemoryEngineInterface):
# Return updated profile
return await self.get_bank_profile(bank_id, request_context=request_context)
- async def submit_async_retain(
+ async def _submit_async_operation(
self,
bank_id: str,
- contents: list[dict[str, Any]],
+ operation_type: str,
+ task_type: str,
+ task_payload: dict[str, Any],
*,
- request_context: "RequestContext",
- document_tags: list[str] | None = None,
+ result_metadata: dict[str, Any] | None = None,
+ dedupe_by_bank: bool = False,
) -> dict[str, Any]:
- """Submit a batch retain operation to run asynchronously."""
- await self._authenticate_tenant(request_context)
+ """Generic helper to submit an async operation.
+
+ Args:
+ bank_id: Bank identifier
+ operation_type: Operation type for the async_operations record (e.g., 'consolidation', 'retain')
+ task_type: Task type for the task payload (e.g., 'consolidation', 'batch_retain')
+ task_payload: Additional task payload fields (operation_id and bank_id are added automatically)
+ result_metadata: Optional metadata to store with the operation record
+ dedupe_by_bank: If True, skip creating a new task if one is already pending for this bank+operation_type
+
+ Returns:
+ Dict with operation_id and optionally deduplicated=True if an existing task was found
+ """
+ import json
+
pool = await self._get_pool()
- import json
+ # Check for existing pending task if deduplication is enabled
+ # Note: We only check 'pending', not 'processing', because a processing task
+ # uses a watermark from when it started - new memories added after that point
+ # would need another consolidation run to be processed.
+ if dedupe_by_bank:
+ async with acquire_with_retry(pool) as conn:
+ existing = await conn.fetchrow(
+ f"""
+ SELECT operation_id FROM {fq_table("async_operations")}
+ WHERE bank_id = $1 AND operation_type = $2 AND status = 'pending'
+ LIMIT 1
+ """,
+ bank_id,
+ operation_type,
+ )
+ if existing:
+ logger.debug(
+ f"{operation_type} task already pending for bank_id={bank_id}, "
+ f"skipping duplicate (existing operation_id={existing['operation_id']})"
+ )
+ return {
+ "operation_id": str(existing["operation_id"]),
+ "deduplicated": True,
+ }
operation_id = uuid.uuid4()
@@ -5378,25 +5522,131 @@ class MemoryEngine(MemoryEngineInterface):
""",
operation_id,
bank_id,
- "retain",
- json.dumps({"items_count": len(contents)}),
+ operation_type,
+ json.dumps(result_metadata or {}),
)
- # Submit task to background queue
- task_payload = {
- "type": "batch_retain",
+ # Build and submit task payload
+ full_payload = {
+ "type": task_type,
"operation_id": str(operation_id),
"bank_id": bank_id,
- "contents": contents,
+ **task_payload,
}
- if document_tags:
- task_payload["document_tags"] = document_tags
- await self._task_backend.submit_task(task_payload)
+ await self._task_backend.submit_task(full_payload)
- logger.info(f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}")
+ logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}")
return {
"operation_id": str(operation_id),
- "items_count": len(contents),
}
+
+ async def submit_async_retain(
+ self,
+ bank_id: str,
+ contents: list[dict[str, Any]],
+ *,
+ request_context: "RequestContext",
+ document_tags: list[str] | None = None,
+ ) -> dict[str, Any]:
+ """Submit a batch retain operation to run asynchronously."""
+ await self._authenticate_tenant(request_context)
+
+ task_payload: dict[str, Any] = {"contents": contents}
+ if document_tags:
+ task_payload["document_tags"] = document_tags
+
+ result = await self._submit_async_operation(
+ bank_id=bank_id,
+ operation_type="retain",
+ task_type="batch_retain",
+ task_payload=task_payload,
+ result_metadata={"items_count": len(contents)},
+ dedupe_by_bank=False,
+ )
+
+ result["items_count"] = len(contents)
+ return result
+
+ async def submit_async_consolidation(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Submit a consolidation operation to run asynchronously.
+
+ Deduplicates by bank_id - if there's already a pending consolidation for this bank,
+ returns the existing operation_id instead of creating a new one.
+
+ Args:
+ bank_id: Bank identifier
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with operation_id
+ """
+ await self._authenticate_tenant(request_context)
+ return await self._submit_async_operation(
+ bank_id=bank_id,
+ operation_type="consolidation",
+ task_type="consolidation",
+ task_payload={},
+ dedupe_by_bank=True,
+ )
+
+ async def submit_async_create_reflection(
+ self,
+ bank_id: str,
+ name: str,
+ source_query: str,
+ *,
+ tags: list[str] | None = None,
+ max_tokens: int = 2048,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Submit an async reflection creation operation.
+
+ This:
+ 1. Creates the reflection in the database immediately (with placeholder content)
+ 2. Schedules a background task to run reflect and update the content
+ 3. Returns operation_id for tracking
+
+ Args:
+ bank_id: Bank identifier
+ name: Human-readable name for the reflection
+ source_query: The query to run to generate content
+ tags: Optional tags for scoped visibility
+ max_tokens: Maximum tokens for the reflect response
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with operation_id
+ """
+ await self._authenticate_tenant(request_context)
+
+ # 1. Create the reflection in the database with placeholder content
+ reflection = await self.create_reflection(
+ bank_id=bank_id,
+ name=name,
+ source_query=source_query,
+ content="Generating content...", # Placeholder
+ tags=tags,
+ request_context=request_context,
+ )
+ reflection_id = reflection["id"]
+
+ # 2. Submit async operation
+ return await self._submit_async_operation(
+ bank_id=bank_id,
+ operation_type="create_reflection",
+ task_type="create_reflection",
+ task_payload={
+ "reflection_id": reflection_id,
+ "source_query": source_query,
+ "max_tokens": max_tokens,
+ },
+ result_metadata={"reflection_id": reflection_id, "name": name, "source_query": source_query},
+ dedupe_by_bank=False,
+ )
diff --git a/hindsight-api/hindsight_api/engine/mental_models/__init__.py b/hindsight-api/hindsight_api/engine/mental_models/__init__.py
index cd0fba08..61f54af2 100644
--- a/hindsight-api/hindsight_api/engine/mental_models/__init__.py
+++ b/hindsight-api/hindsight_api/engine/mental_models/__init__.py
@@ -1,16 +1,12 @@
"""
Mental models module for Hindsight.
-Mental models are synthesized summaries that represent understanding. They come
-in different subtypes based on how they were created:
+Mental models contain directives - hard rules that are injected into reflect prompts.
+Directives are user-defined and their observations are user-provided (not LLM-generated).
-- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team")
- These are created upfront based on what any agent with this role would need.
-
-- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.)
- These surface organically as facts are retained.
-
-- Pinned: User-defined models that persist across refreshes.
+Other types of consolidated knowledge are handled by:
+- Learnings: Automatic bottom-up consolidation from facts
+- Pinned Reflections: User-curated living documents
"""
from .models import MentalModel, MentalModelSubtype
diff --git a/hindsight-api/hindsight_api/engine/mental_models/emergent.py b/hindsight-api/hindsight_api/engine/mental_models/emergent.py
deleted file mode 100644
index ec5c8925..00000000
--- a/hindsight-api/hindsight_api/engine/mental_models/emergent.py
+++ /dev/null
@@ -1,311 +0,0 @@
-"""
-Emergent mental model detection and promotion.
-
-Emergent models are discovered from data patterns:
-- Named entity extraction (people, projects, systems)
-- Temporal clustering (events with multiple references)
-- Causal patterns ("Because X, we do Y")
-- Behavioral anchors ("After X, we started Y")
-- Reference frequency (anything mentioned repeatedly)
-
-When a pattern is detected, it goes through a mission filter to check relevance,
-and if relevant, is promoted to a mental model.
-"""
-
-import logging
-from typing import TYPE_CHECKING
-
-from pydantic import BaseModel, Field
-
-from .models import EmergentCandidate
-
-if TYPE_CHECKING:
- from ..llm_wrapper import LLMConfig
-
-logger = logging.getLogger(__name__)
-
-
-class MissionFilterCandidate(BaseModel):
- """Result of mission filtering for a single candidate."""
-
- name: str
- promote: bool = Field(description="True if this is a specific named entity worth tracking")
- reason: str = Field(description="Brief explanation for the decision")
-
-
-class MissionFilterResponse(BaseModel):
- """Response from LLM for mission filtering."""
-
- candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
-
-
-def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
- """Build the prompt for filtering candidates by mission relevance."""
- candidate_list = "\n".join(
- [f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
- )
-
- return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
-
-MISSION: {mission}
-
-DETECTED ENTITIES:
-{candidate_list}
-
-=== DECISION RULES ===
-
-Set promote=true ONLY for specific, named entities:
-- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
-- Named organizations: "Google", "Acme Corp", "Frontend Team"
-- Named places: "Central Park Zoo", "NYC Office", "Building A"
-- Named projects: "Project Phoenix", "Auth Service v2"
-
-Set promote=false for EVERYTHING ELSE, including:
-- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
-- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
-- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
-
-THE TEST: Is this a specific name you'd find in a contact list or org chart?
-- "John" → YES (promote=true)
-- "kids" → NO (promote=false)
-- "community" → NO (promote=false)
-- "Maria" → YES (promote=true)
-- "park" → NO (promote=false)
-
-When in doubt, set promote=false."""
-
-
-def get_mission_filter_system_message() -> str:
- """System message for mission filtering."""
- return """You filter entities for promotion. Output JSON with 'candidates' array.
-
-Rules:
-- promote=true ONLY for specific names (people, organizations, named places/projects)
-- promote=false for common words, generic categories, abstract concepts
-
-Examples:
-- "John" → promote=true (person name)
-- "kids" → promote=false (generic category)
-- "community" → promote=false (abstract concept)
-- "Google" → promote=true (organization name)
-- "motivation" → promote=false (abstract concept)
-
-When in doubt, promote=false. Most entities should be rejected."""
-
-
-async def filter_candidates_by_mission(
- llm_config: "LLMConfig",
- mission: str,
- candidates: list[EmergentCandidate],
-) -> list[EmergentCandidate]:
- """
- Filter emergent candidates to keep only specific, named entities.
-
- Args:
- llm_config: LLM configuration
- mission: The bank's mission (used for context)
- candidates: List of detected candidates
-
- Returns:
- Filtered list of candidates that are specific named entities
- """
- if not candidates:
- return []
-
- if not mission:
- # No mission = no filtering, keep all candidates
- logger.debug("[EMERGENT] No mission set, skipping filter")
- return candidates
-
- prompt = build_mission_filter_prompt(mission, candidates)
-
- try:
- result = await llm_config.call(
- messages=[
- {"role": "system", "content": get_mission_filter_system_message()},
- {"role": "user", "content": prompt},
- ],
- response_format=MissionFilterResponse,
- scope="mental_model_mission_filter",
- )
-
- # Build name -> promote map
- promote_map = {c.name: c.promote for c in result.candidates}
-
- # Filter candidates
- filtered = []
- for candidate in candidates:
- if candidate.name in promote_map:
- if promote_map[candidate.name]:
- filtered.append(candidate)
- logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
- else:
- logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
- else:
- # Candidate not in response - reject by default
- logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
-
- logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
- return filtered
-
- except Exception as e:
- logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
- return []
-
-
-async def evaluate_emergent_models(
- llm_config: "LLMConfig",
- models: list[dict],
-) -> list[str]:
- """
- Evaluate existing emergent models to check if they should be kept.
-
- This re-evaluates emergent models using the same filtering criteria
- as new candidates. Models that are generic/abstract will be removed.
-
- Args:
- llm_config: LLM configuration
- models: List of existing emergent model dicts with 'name', 'id'
-
- Returns:
- List of model IDs that should be REMOVED (no longer valid)
- """
- if not models:
- return []
-
- # Convert existing models to candidates for evaluation
- candidates = [
- EmergentCandidate(
- name=m["name"],
- detection_method="existing_emergent_model",
- mention_count=0,
- )
- for m in models
- ]
-
- # Build a simple prompt for re-evaluation
- names_list = "\n".join([f"- {m['name']}" for m in models])
- prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
-
-EXISTING MODELS:
-{names_list}
-
-=== DECISION RULES ===
-
-Set promote=true ONLY for specific, named entities:
-- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
-- Named organizations: "Google", "Acme Corp", "Frontend Team"
-- Named places: "Central Park Zoo", "NYC Office", "Building A"
-- Named projects: "Project Phoenix", "Auth Service v2"
-
-Set promote=false for EVERYTHING ELSE, including:
-- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
-- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
-- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
-
-THE TEST: Is this a specific name you'd find in a contact list or org chart?
-- "John" → YES (promote=true)
-- "kids" → NO (promote=false)
-- "community" → NO (promote=false)
-
-When in doubt, set promote=false."""
-
- try:
- result = await llm_config.call(
- messages=[
- {"role": "system", "content": get_mission_filter_system_message()},
- {"role": "user", "content": prompt},
- ],
- response_format=MissionFilterResponse,
- scope="mental_model_emergent_evaluation",
- )
-
- # Build name -> promote map
- promote_map = {c.name: c.promote for c in result.candidates}
-
- # Find models to remove
- models_to_remove = []
- for model in models:
- name = model["name"]
- if name in promote_map:
- if not promote_map[name]:
- models_to_remove.append(model["id"])
- else:
- logger.debug(f"[EMERGENT] Keeping '{name}'")
- else:
- # Model not in response - remove to be safe
- logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
- models_to_remove.append(model["id"])
-
- logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
- return models_to_remove
-
- except Exception as e:
- logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
- return []
-
-
-async def detect_entity_candidates(
- pool,
- bank_id: str,
- min_mentions: int = 5,
- top_percent: int = 20,
-) -> list[EmergentCandidate]:
- """
- Detect entities that are candidates for promotion to mental models.
-
- Args:
- pool: Database connection pool
- bank_id: Bank identifier
- min_mentions: Minimum mention count to consider
- top_percent: Only consider top X% by mention count
-
- Returns:
- List of entity candidates
- """
- from ..db_utils import acquire_with_retry
- from ..memory_engine import fq_table
-
- candidates = []
-
- async with acquire_with_retry(pool) as conn:
- # Get entities that meet criteria and don't already have mental models
- rows = await conn.fetch(
- f"""
- WITH ranked AS (
- SELECT
- e.id,
- e.canonical_name,
- e.mention_count,
- PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
- FROM {fq_table("entities")} e
- LEFT JOIN {fq_table("mental_models")} mm
- ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
- WHERE e.bank_id = $1
- AND e.mention_count >= $2
- AND mm.id IS NULL -- Not already a mental model
- )
- SELECT id, canonical_name, mention_count
- FROM ranked
- WHERE rank_pct <= $3
- ORDER BY mention_count DESC
- LIMIT 50
- """,
- bank_id,
- min_mentions,
- top_percent / 100.0,
- )
-
- for row in rows:
- candidates.append(
- EmergentCandidate(
- name=row["canonical_name"],
- detection_method="named_entity_extraction",
- mention_count=row["mention_count"],
- entity_id=str(row["id"]),
- relevance_score=0.0,
- )
- )
-
- logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
- return candidates
diff --git a/hindsight-api/hindsight_api/engine/mental_models/models.py b/hindsight-api/hindsight_api/engine/mental_models/models.py
index 12353111..e1ff9fea 100644
--- a/hindsight-api/hindsight_api/engine/mental_models/models.py
+++ b/hindsight-api/hindsight_api/engine/mental_models/models.py
@@ -9,12 +9,14 @@ from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
- """Subtype of mental model - how it was created."""
+ """Subtype of mental model.
+
+ Currently only DIRECTIVE is supported. Other types of consolidated knowledge
+ are handled by:
+ - Learnings: Automatic bottom-up consolidation from facts
+ - Pinned Reflections: User-curated living documents
+ """
- STRUCTURAL = "structural" # Derived from mission, created upfront
- EMERGENT = "emergent" # Discovered from data patterns
- LEARNED = "learned" # Formed through reflection
- PINNED = "pinned" # User-defined topic, observations LLM-generated
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
@@ -49,50 +51,3 @@ class MentalModel(BaseModel):
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
-
-
-class StructuralModelTemplate(BaseModel):
- """
- A template for a structural mental model.
-
- Generated by LLM based on the bank's mission. Represents what any agent
- with this role would need to track.
- """
-
- id: str = Field(default="", description="Existing model ID to keep, or empty for new models")
- name: str = Field(description="Human-readable name")
- description: str = Field(description="What this model should track")
- initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model")
-
-
-class StructuralModelDerivationResponse(BaseModel):
- """Response from LLM for structural model derivation."""
-
- templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
-
-
-class EmergentCandidate(BaseModel):
- """
- A candidate for promotion to emergent mental model.
-
- Detected through pattern analysis of facts.
- """
-
- name: str = Field(description="Name of the detected pattern/entity")
- detection_method: str = Field(description="How this candidate was detected")
- mention_count: int = Field(default=0, description="How many times referenced")
- entity_id: str | None = Field(default=None, description="Entity ID if detected as entity")
- relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)")
-
-
-class ResearchResult(BaseModel):
- """
- Result from the research endpoint.
-
- Contains the answer along with the mental models and facts used.
- """
-
- answer: str = Field(description="The synthesized answer")
- mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed")
- facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed")
- question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)")
diff --git a/hindsight-api/hindsight_api/engine/mental_models/structural.py b/hindsight-api/hindsight_api/engine/mental_models/structural.py
deleted file mode 100644
index e27d0789..00000000
--- a/hindsight-api/hindsight_api/engine/mental_models/structural.py
+++ /dev/null
@@ -1,228 +0,0 @@
-"""
-Structural mental model derivation from bank mission.
-
-Structural models are derived from the bank's mission - they represent what
-any agent with this role would need to track. For example:
-
-Mission: "Be a PM for engineering team"
-Structural models:
- - Team Structure (who's on the team, roles)
- - Project Overview (current projects, status)
- - Processes (how releases work, how decisions are made)
- - Key Systems (what we own, dependencies)
-"""
-
-import logging
-from typing import TYPE_CHECKING
-
-from pydantic import BaseModel, Field
-
-from .models import StructuralModelTemplate
-
-if TYPE_CHECKING:
- from ..llm_wrapper import LLMConfig
-
-logger = logging.getLogger(__name__)
-
-
-class StructuralDerivationResponse(BaseModel):
- """Response from LLM for structural model derivation."""
-
- templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
-
-
-class StructuralRelevanceResult(BaseModel):
- """Result of evaluating a structural model's relevance to the mission."""
-
- name: str
- relevant: bool
- reason: str
-
-
-class StructuralRelevanceResponse(BaseModel):
- """Response from LLM for structural model relevance evaluation."""
-
- models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model")
-
-
-def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str:
- """Build the prompt for deriving structural models from a mission."""
- existing_section = ""
- if existing_models:
- model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models])
- existing_section = f"""
-EXISTING STRUCTURAL MODELS:
-{model_list}
-
-IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value.
-Models not included in your output will be REMOVED.
-"""
-
- return f"""Given this agent mission, identify the KEY THINGS to track to achieve it.
-
-MISSION: {mission}
-{existing_section}
-IMPORTANT CONSTRAINTS:
-- Return 0-3 structural models MAXIMUM (less is better!)
-- Only include models for SPECIFIC, CONCRETE things the agent needs to track
-- Each model must be DIRECTLY tied to achieving the mission
-- If the mission is simple, return 0 models (empty array is fine)
-- If existing models are provided and you want to keep one, use its EXACT id
-- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists)
-
-GOOD examples (specific, actionable):
-- Mission: "Be a PM for engineering team" → "Team Members" (track who's on the team)
-- Mission: "Track customer feedback" → "Customer Issues" (track specific complaints/requests)
-- Mission: "Manage project X" → "Project X Milestones" (track progress)
-
-BAD examples (too generic, don't create these):
-- "Processes", "Workflows", "Key Systems", "Important Events"
-- "Communication", "Collaboration", "Progress", "Status"
-- Generic role-based models not tied to the specific mission
-
-For each model:
-1. id: Use EXACT existing id if keeping a model, or leave empty for new models
-2. name: Short, specific name (e.g., "Team Members", "Sprint Goals")
-3. description: One line describing what to track
-4. initial_probes: 2-3 search queries to find relevant information
-
-Return ONLY the models that should exist. Existing models not in your output will be deleted."""
-
-
-def get_structural_derivation_system_message() -> str:
- """System message for structural model derivation."""
- return """You identify the key things to track for a mission. Be VERY selective.
-
-Rules:
-- Maximum 3 models (prefer fewer)
-- Only SPECIFIC, CONCRETE things - not generic categories
-- Each must DIRECTLY help achieve the mission
-- Empty array is valid if no models are truly needed
-- If existing models are shown and you want to keep one, return its EXACT id
-- Never create duplicates - if a similar model exists, keep the existing one
-
-Output JSON with 'templates' array (can be empty)."""
-
-
-def _normalize_id(text: str) -> str:
- """Normalize a string to a canonical form for comparison.
-
- Removes common suffixes, pluralization, and normalizes separators.
- """
- # Lowercase and normalize separators
- normalized = text.lower().replace(" ", "-").replace("_", "-")
-
- # Remove common suffixes that indicate the same concept
- suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"]
- for suffix in suffixes_to_remove:
- if normalized.endswith(suffix) and len(normalized) > len(suffix):
- normalized = normalized[: -len(suffix)]
-
- return normalized
-
-
-def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None:
- """Find an existing model ID that is similar to the new ID.
-
- Returns the existing ID if a similar one is found, None otherwise.
- """
- if not existing_models:
- return None
-
- new_normalized = _normalize_id(new_id)
-
- for model in existing_models:
- existing_id = model.get("id", "")
- existing_normalized = _normalize_id(existing_id)
-
- # Check if one is a prefix of the other (normalized)
- if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized):
- return existing_id
-
- # Check if they're the same when normalized
- if new_normalized == existing_normalized:
- return existing_id
-
- return None
-
-
-async def derive_structural_models(
- llm_config: "LLMConfig",
- mission: str,
- existing_models: list[dict] | None = None,
-) -> tuple[list[StructuralModelTemplate], list[str]]:
- """
- Derive structural model templates from a bank's mission.
-
- This combines derivation and evaluation in one call. The LLM sees existing
- models and decides which to keep. Any existing model not in the output
- will be marked for removal.
-
- Args:
- llm_config: LLM configuration for calling the model
- mission: The bank's mission (e.g., "Be a PM for engineering team")
- existing_models: Optional list of existing model dicts with 'name', 'description', 'id'
-
- Returns:
- Tuple of (templates to create/keep, IDs of existing models to remove)
-
- Raises:
- Exception: If LLM call fails
- """
- prompt = build_structural_derivation_prompt(mission, existing_models)
-
- result = await llm_config.call(
- messages=[
- {"role": "system", "content": get_structural_derivation_system_message()},
- {"role": "user", "content": prompt},
- ],
- response_format=StructuralDerivationResponse,
- scope="mental_model_structural_derivation",
- )
-
- templates = result.templates
- logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models")
-
- # Build set of existing IDs for quick lookup
- existing_ids = {m["id"] for m in existing_models} if existing_models else set()
-
- # Process templates: validate IDs, deduplicate, assign stable IDs
- processed_templates: list[StructuralModelTemplate] = []
- kept_existing_ids: set[str] = set()
-
- for template in templates:
- # If LLM returned an ID, check if it's a valid existing ID
- if template.id and template.id in existing_ids:
- # LLM is keeping an existing model
- kept_existing_ids.add(template.id)
- processed_templates.append(template)
- logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}")
- else:
- # New model or LLM didn't return a valid ID
- # Generate ID from name
- generated_id = template.name.lower().replace(" ", "-").replace("_", "-")
-
- # Check for similar existing models to prevent near-duplicates
- similar_id = _find_similar_existing_id(generated_id, existing_models)
- if similar_id and similar_id not in kept_existing_ids:
- # Use the existing similar model instead of creating a new one
- logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'")
- template.id = similar_id
- kept_existing_ids.add(similar_id)
- else:
- template.id = generated_id
-
- processed_templates.append(template)
-
- # Find existing models to remove (not kept in LLM output)
- models_to_remove = []
- if existing_models:
- for model in existing_models:
- if model["id"] not in kept_existing_ids:
- logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal")
- models_to_remove.append(model["id"])
-
- if models_to_remove:
- logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed")
-
- return processed_templates, models_to_remove
diff --git a/hindsight-api/hindsight_api/engine/reflect/agent.py b/hindsight-api/hindsight_api/engine/reflect/agent.py
index bd9d38af..4f09d5b6 100644
--- a/hindsight-api/hindsight_api/engine/reflect/agent.py
+++ b/hindsight-api/hindsight_api/engine/reflect/agent.py
@@ -1,5 +1,10 @@
"""
Reflect agent - agentic loop for reflection with native tool calling.
+
+Uses hierarchical retrieval:
+1. search_reflections - User-curated summaries (highest quality)
+2. search_mental_models - Consolidated knowledge with freshness
+3. recall - Raw facts as ground truth
"""
import asyncio
@@ -8,7 +13,7 @@ import logging
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
-from .models import DirectiveInfo, LLMCall, MentalModelInput, ReflectAgentResult, ToolCall
+from .models import DirectiveInfo, LLMCall, ReflectAgentResult, ToolCall
from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools
from .tools_schema import get_reflect_tools
@@ -46,6 +51,32 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_ITERATIONS = 10
+def _normalize_tool_name(name: str) -> str:
+ """Normalize tool name from various LLM output formats.
+
+ Some LLMs output tool names in non-standard formats:
+ - 'functions.done' (OpenAI-style prefix)
+ - 'call=functions.done' (some models)
+ - 'call=done' (some models)
+
+ Returns the normalized tool name (e.g., 'done', 'recall', etc.)
+ """
+ # Handle 'call=functions.name' or 'call=name' format
+ if name.startswith("call="):
+ name = name[len("call=") :]
+
+ # Handle 'functions.name' format
+ if name.startswith("functions."):
+ name = name[len("functions.") :]
+
+ return name
+
+
+def _is_done_tool(name: str) -> bool:
+ """Check if the tool name represents the 'done' tool."""
+ return _normalize_tool_name(name) == "done"
+
+
async def _generate_structured_output(
answer: str,
response_schema: dict,
@@ -153,10 +184,10 @@ async def run_reflect_agent(
bank_id: str,
query: str,
bank_profile: dict[str, Any],
- lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
+ search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
+ search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
- learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
context: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
max_tokens: int | None = None,
@@ -166,19 +197,20 @@ async def run_reflect_agent(
"""
Execute the reflect agent loop using native tool calling.
- The agent iteratively calls tools to gather information and learn,
- then provides a final answer via the done() tool.
+ The agent uses hierarchical retrieval:
+ 1. search_reflections - User-curated summaries (try first)
+ 2. search_mental_models - Consolidated knowledge with freshness
+ 3. recall - Raw facts as ground truth
Args:
llm_config: LLM provider for agent calls
bank_id: Bank identifier
query: Question to answer
bank_profile: Bank profile with name and mission
- lookup_fn: Tool callback for lookup (model_id) -> result
+ search_reflections_fn: Tool callback for searching reflections (query, max_results) -> result
+ search_mental_models_fn: Tool callback for searching mental models (query, max_results) -> result
recall_fn: Tool callback for recall (query, max_tokens) -> result
- expand_fn: Tool callback for expand (memory_id, depth) -> result
- learn_fn: Optional tool callback for learn (MentalModelInput) -> result.
- If None, learn tool is disabled.
+ expand_fn: Tool callback for expand (memory_ids, depth) -> result
context: Optional additional context
max_iterations: Maximum number of iterations before forcing response
max_tokens: Maximum tokens for the final response
@@ -188,7 +220,6 @@ async def run_reflect_agent(
Returns:
ReflectAgentResult with final answer and metadata
"""
- enable_learn = learn_fn is not None
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
start_time = time.time()
@@ -199,7 +230,7 @@ async def run_reflect_agent(
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
- tools = get_reflect_tools(enable_learn=enable_learn, directive_rules=directive_rules)
+ tools = get_reflect_tools(directive_rules=directive_rules)
# Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools(bank_profile, context, directives=directives)
@@ -209,7 +240,6 @@ async def run_reflect_agent(
]
# Tracking
- mental_models_created: list[str] = []
total_tools_called = 0
tool_trace: list[ToolCall] = []
tool_trace_summary: list[dict[str, Any]] = []
@@ -218,45 +248,8 @@ async def run_reflect_agent(
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
- available_model_ids: set[str] = set()
-
- # Pre-fetch mental models so the agent always starts with this knowledge
- prefetch_start = time.time()
- models_result = await lookup_fn(None) # List all mental models
- prefetch_duration = int((time.time() - prefetch_start) * 1000)
-
- # Track available model IDs
- if isinstance(models_result, dict) and "models" in models_result:
- for model in models_result["models"]:
- if "id" in model:
- available_model_ids.add(model["id"])
-
- # Add to context history for the agent
- context_history.append({"tool": "list_mental_models", "output": models_result})
-
- # Add to tool trace
- tool_trace.append(
- ToolCall(
- tool="list_mental_models",
- input={"tool": "list_mental_models"},
- output=models_result,
- duration_ms=prefetch_duration,
- iteration=0,
- )
- )
- tool_trace_summary.append(
- {
- "tool": "list_mental_models",
- "input_summary": "(prefetch)",
- "duration_ms": prefetch_duration,
- "output_chars": len(json.dumps(models_result, default=str)),
- }
- )
- total_tools_called += 1
-
- # Include in the user message so the agent sees it
- models_info = json.dumps(models_result, indent=2, default=str)
- messages[1]["content"] = f"{query}\n\n## Available Mental Models (pre-fetched)\n```json\n{models_info}\n```"
+ available_reflection_ids: set[str] = set()
+ available_mental_model_ids: set[str] = set()
def _get_llm_trace() -> list[LLMCall]:
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
@@ -315,7 +308,6 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
- mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -334,12 +326,14 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
llm_trace.append({"scope": f"agent_{iteration + 1}", "duration_ms": llm_duration})
- except Exception:
- llm_trace.append(
- {"scope": f"agent_{iteration + 1}_err", "duration_ms": int((time.time() - llm_start) * 1000)}
- )
+ except Exception as e:
+ err_duration = int((time.time() - llm_start) * 1000)
+ logger.warning(f"[REFLECT {reflect_id}] LLM error on iteration {iteration + 1}: {e} ({err_duration}ms)")
+ llm_trace.append({"scope": f"agent_{iteration + 1}_err", "duration_ms": err_duration})
# Guardrail: If no evidence gathered yet, retry
- has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
+ has_gathered_evidence = (
+ bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids)
+ )
if not has_gathered_evidence and iteration < max_iterations - 1:
continue
prompt = build_final_prompt(query, context_history, bank_profile, context)
@@ -366,7 +360,6 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
- mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -390,7 +383,6 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
- mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -420,17 +412,18 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
- mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
)
- # Check for done tool call (handle both 'done' and 'functions.done')
- done_call = next((tc for tc in result.tool_calls if tc.name == "done" or tc.name == "functions.done"), None)
+ # Check for done tool call (handle various LLM output formats)
+ done_call = next((tc for tc in result.tool_calls if _is_done_tool(tc.name)), None)
if done_call:
# Guardrail: Require evidence before done
- has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
+ has_gathered_evidence = (
+ bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids)
+ )
if not has_gathered_evidence and iteration < max_iterations - 1:
# Add assistant message and fake tool result asking for evidence
messages.append(
@@ -445,7 +438,7 @@ async def run_reflect_agent(
"tool_call_id": done_call.id,
"content": json.dumps(
{
- "error": "You must call recall() or list_mental_models() to gather evidence before providing your final answer."
+ "error": "You must search for information first. Use search_reflections(), search_mental_models(), or recall() before providing your final answer."
}
),
}
@@ -456,10 +449,10 @@ async def run_reflect_agent(
return await _process_done_tool(
done_call,
available_memory_ids,
- available_model_ids,
+ available_reflection_ids,
+ available_mental_model_ids,
iteration + 1,
total_tools_called,
- mental_models_created,
tool_trace,
_get_llm_trace(),
_log_completion,
@@ -469,8 +462,8 @@ async def run_reflect_agent(
response_schema=response_schema,
)
- # Execute other tools in parallel (exclude done and functions.done)
- other_tools = [tc for tc in result.tool_calls if tc.name not in ("done", "functions.done")]
+ # Execute other tools in parallel (exclude done tool in all its format variants)
+ other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
if other_tools:
# Add assistant message with tool calls
messages.append(
@@ -482,7 +475,14 @@ async def run_reflect_agent(
# Execute tools in parallel
tool_tasks = [
- _execute_tool_with_timing(tc, lookup_fn, recall_fn, expand_fn, learn_fn) for tc in other_tools
+ _execute_tool_with_timing(
+ tc,
+ search_reflections_fn,
+ search_mental_models_fn,
+ recall_fn,
+ expand_fn,
+ )
+ for tc in other_tools
]
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
total_tools_called += len(other_tools)
@@ -490,38 +490,46 @@ async def run_reflect_agent(
# Process results and add to messages
for tc, result_data in zip(other_tools, tool_results):
if isinstance(result_data, Exception):
- # Tool execution failed - log and raise to fail the request
- logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
- raise RuntimeError(f"Reflect tool '{tc.name}' failed: {result_data}")
+ # Tool execution failed - send error back to LLM so it can try again
+ logger.warning(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
+ output = {"error": f"Tool execution failed: {result_data}"}
+ duration_ms = 0
+ else:
+ output, duration_ms = result_data
- output, duration_ms = result_data
+ # Normalize tool name for consistent tracking
+ normalized_tool_name = _normalize_tool_name(tc.name)
- # Check if tool returned an error response
+ # Check if tool returned an error response - log but continue (LLM will see the error)
if isinstance(output, dict) and "error" in output:
- logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} returned error: {output['error']}")
- raise RuntimeError(f"Reflect tool '{tc.name}' error: {output['error']}")
+ logger.warning(
+ f"[REFLECT {reflect_id}] Tool {normalized_tool_name} returned error: {output['error']}"
+ )
- # Track created mental models
- if tc.name == "learn" and isinstance(output, dict) and "model_id" in output:
- mental_models_created.append(output["model_id"])
+ # Track available IDs from tool results (only for successful responses)
+ if (
+ normalized_tool_name == "search_reflections"
+ and isinstance(output, dict)
+ and "reflections" in output
+ ):
+ for reflection in output["reflections"]:
+ if "id" in reflection:
+ available_reflection_ids.add(reflection["id"])
- # Track available memory IDs from recall
- if tc.name == "recall" and isinstance(output, dict) and "memories" in output:
+ if (
+ normalized_tool_name == "search_mental_models"
+ and isinstance(output, dict)
+ and "mental_models" in output
+ ):
+ for mm in output["mental_models"]:
+ if "id" in mm:
+ available_mental_model_ids.add(mm["id"])
+
+ if normalized_tool_name == "recall" and isinstance(output, dict) and "memories" in output:
for memory in output["memories"]:
if "id" in memory:
available_memory_ids.add(memory["id"])
- # Track available model IDs
- if tc.name in ("list_mental_models", "get_mental_model") and isinstance(output, dict):
- if output.get("found") and "model" in output:
- model_id = output["model"].get("id")
- if model_id:
- available_model_ids.add(model_id)
- elif "models" in output:
- for model in output["models"]:
- if "id" in model:
- available_model_ids.add(model["id"])
-
# Add tool result message
messages.append(
{
@@ -565,7 +573,6 @@ async def run_reflect_agent(
text=answer,
iterations=max_iterations,
tools_called=total_tools_called,
- mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -587,10 +594,10 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
async def _process_done_tool(
done_call: "LLMToolCall",
available_memory_ids: set[str],
- available_model_ids: set[str],
+ available_reflection_ids: set[str],
+ available_mental_model_ids: set[str],
iterations: int,
total_tools_called: int,
- mental_models_created: list[str],
tool_trace: list[ToolCall],
llm_trace: list[LLMCall],
log_completion: Callable,
@@ -606,9 +613,10 @@ async def _process_done_tool(
if not answer:
answer = "No answer provided."
- # Validate IDs
+ # Validate IDs (only include IDs that were actually retrieved)
used_memory_ids = [mid for mid in args.get("memory_ids", []) if mid in available_memory_ids]
- used_model_ids = [mid for mid in args.get("model_ids", []) if mid in available_model_ids]
+ used_reflection_ids = [rid for rid in args.get("reflection_ids", []) if rid in available_reflection_ids]
+ used_mental_model_ids = [mid for mid in args.get("mental_model_ids", []) if mid in available_mental_model_ids]
# Generate structured output if schema provided
structured_output = None
@@ -621,25 +629,32 @@ async def _process_done_tool(
structured_output=structured_output,
iterations=iterations,
tools_called=total_tools_called,
- mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=llm_trace,
used_memory_ids=used_memory_ids,
- used_model_ids=used_model_ids,
+ used_reflection_ids=used_reflection_ids,
+ used_mental_model_ids=used_mental_model_ids,
directives_applied=directives_applied,
)
async def _execute_tool_with_timing(
tc: "LLMToolCall",
- lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
+ search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
+ search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
- learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
start = time.time()
- result = await _execute_tool(tc.name, tc.arguments, lookup_fn, recall_fn, expand_fn, learn_fn)
+ result = await _execute_tool(
+ tc.name,
+ tc.arguments,
+ search_reflections_fn,
+ search_mental_models_fn,
+ recall_fn,
+ expand_fn,
+ )
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
@@ -647,24 +662,28 @@ async def _execute_tool_with_timing(
async def _execute_tool(
tool_name: str,
args: dict[str, Any],
- lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
+ search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
+ search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
- learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
) -> dict[str, Any]:
"""Execute a single tool by name."""
- # Normalize tool name - some LLMs return 'functions.done' instead of 'done'
- if tool_name.startswith("functions."):
- tool_name = tool_name[len("functions.") :]
+ # Normalize tool name for various LLM output formats
+ tool_name = _normalize_tool_name(tool_name)
- if tool_name == "list_mental_models":
- return await lookup_fn(None)
+ if tool_name == "search_reflections":
+ query = args.get("query")
+ if not query:
+ return {"error": "search_reflections requires a query parameter"}
+ max_results = args.get("max_results") or 5
+ return await search_reflections_fn(query, max_results)
- elif tool_name == "get_mental_model":
- model_id = args.get("model_id")
- if not model_id:
- return {"error": "get_mental_model requires model_id"}
- return await lookup_fn(model_id)
+ elif tool_name == "search_mental_models":
+ query = args.get("query")
+ if not query:
+ return {"error": "search_mental_models requires a query parameter"}
+ max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
+ return await search_mental_models_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
@@ -673,15 +692,6 @@ async def _execute_tool(
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
return await recall_fn(query, max_tokens)
- elif tool_name == "learn":
- if learn_fn is None:
- return {"error": "learn tool is not available"}
- name = args.get("name")
- description = args.get("description")
- if not name or not description:
- return {"error": "learn requires name and description"}
- return await learn_fn(MentalModelInput(name=name, description=description))
-
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
if not memory_ids:
@@ -695,21 +705,22 @@ async def _execute_tool(
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
"""Create a summary of tool input for logging, showing all params."""
- if tool_name == "list_mental_models":
- return "()"
- elif tool_name == "get_mental_model":
- return f"(model_id={args.get('model_id', '?')})"
+ if tool_name == "search_reflections":
+ query = args.get("query", "")
+ query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
+ max_results = args.get("max_results") or 5
+ return f"(query={query_preview}, max_results={max_results})"
+ elif tool_name == "search_mental_models":
+ query = args.get("query", "")
+ query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
+ max_tokens = max(args.get("max_tokens") or 5000, 1000)
+ return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
# Show actual value used (default 2048, min 1000)
max_tokens = max(args.get("max_tokens") or 2048, 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
- elif tool_name == "learn":
- name = args.get("name", "?")
- desc = args.get("description", "")
- desc_preview = f"'{desc[:20]}...'" if len(desc) > 20 else f"'{desc}'"
- return f"(name='{name}', description={desc_preview})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
depth = args.get("depth", "chunk")
@@ -718,6 +729,9 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
answer = args.get("answer", "")
answer_preview = f"'{answer[:30]}...'" if len(answer) > 30 else f"'{answer}'"
memory_ids = args.get("memory_ids", [])
- model_ids = args.get("model_ids", [])
- return f"(answer={answer_preview}, memory_ids={len(memory_ids)}, model_ids={len(model_ids)})"
+ reflection_ids = args.get("reflection_ids", [])
+ mental_model_ids = args.get("mental_model_ids", [])
+ return (
+ f"(answer={answer_preview}, mem={len(memory_ids)}, ref={len(reflection_ids)}, mm={len(mental_model_ids)})"
+ )
return str(args)
diff --git a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py b/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
deleted file mode 100644
index df7fab15..00000000
--- a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
+++ /dev/null
@@ -1,1213 +0,0 @@
-"""
-Diff-Based Mental Model Reflect Agent.
-
-This module implements a multi-phase agentic loop for generating and updating
-mental model observations with evidence-grounded quotes and computed trends.
-
-Phases:
-0. UPDATE EXISTING: Search for new evidence for existing observations
-1. SEED: Generate NEW candidate observations (skipping already-tracked patterns)
-2. EVIDENCE HUNT: For each new candidate, search for supporting/contradicting evidence
-3. VALIDATE: Validate new candidates and extract quotes
-4. COMPARE: Merge updated existing + new validated observations
-"""
-
-import asyncio
-import hashlib
-import json
-import logging
-import time
-from datetime import datetime, timezone
-from typing import TYPE_CHECKING, Any, Awaitable, Callable
-
-from pydantic import BaseModel, Field, field_validator
-
-from .observations import (
- CandidateObservation,
- CandidateWithEvidence,
- Observation,
- ObservationEvidence,
- verify_evidence_quotes,
-)
-from .prompts import (
- COMPARE_PHASE_SYSTEM_PROMPT,
- SEED_PHASE_SYSTEM_PROMPT,
- UPDATE_EXISTING_SYSTEM_PROMPT,
- VALIDATE_PHASE_SYSTEM_PROMPT,
- build_compare_phase_prompt,
- build_seed_phase_prompt,
- build_update_existing_prompt,
- build_validate_phase_prompt,
-)
-
-if TYPE_CHECKING:
- from ..llm_wrapper import LLMProvider
-
-logger = logging.getLogger(__name__)
-
-
-# =============================================================================
-# Typed Models for Refresh State Tracking
-# =============================================================================
-
-
-class DispositionTraits(BaseModel):
- """Disposition traits for a memory bank."""
-
- skepticism: int = Field(default=3, ge=1, le=5)
- literalism: int = Field(default=3, ge=1, le=5)
- empathy: int = Field(default=3, ge=1, le=5)
-
-
-class BankProfile(BaseModel):
- """Bank profile with mission and disposition."""
-
- bank_id: str = Field(default="")
- name: str = Field(default="")
- mission: str | None = Field(default=None)
- disposition: DispositionTraits = Field(default_factory=DispositionTraits)
-
- @field_validator("disposition", mode="before")
- @classmethod
- def parse_disposition(cls, v: DispositionTraits | dict | None) -> DispositionTraits:
- """Parse disposition from various formats."""
- if v is None:
- return DispositionTraits()
- if isinstance(v, DispositionTraits):
- return v
- if isinstance(v, dict):
- return DispositionTraits.model_validate(v)
- # Handle Pydantic v1 style models
- if hasattr(v, "model_dump"):
- return DispositionTraits.model_validate(v.model_dump())
- return DispositionTraits()
-
-
-class DirectiveObservation(BaseModel):
- """A single observation in a directive mental model."""
-
- title: str = Field(default="")
- content: str = Field(default="")
- text: str = Field(default="") # Legacy field
-
- @property
- def effective_content(self) -> str:
- """Get content, falling back to text for legacy data."""
- return self.content or self.text
-
-
-class DirectiveMentalModel(BaseModel):
- """A directive mental model with its observations."""
-
- id: str = Field(default="")
- name: str = Field(default="")
- observations: list[DirectiveObservation] = Field(default_factory=list)
-
- @field_validator("observations", mode="before")
- @classmethod
- def parse_observations(cls, v: list | None) -> list[DirectiveObservation]:
- """Parse observations from various formats."""
- if not v:
- return []
- result = []
- for obs in v:
- if isinstance(obs, DirectiveObservation):
- result.append(obs)
- elif isinstance(obs, dict):
- result.append(DirectiveObservation.model_validate(obs))
- return result
-
-
-class RefreshState(BaseModel):
- """State snapshot at the time of last refresh.
-
- Used to determine if a refresh is needed by comparing current state
- against this stored snapshot.
- """
-
- last_refresh_at: str = Field(description="ISO timestamp of last refresh")
- memories_count: int = Field(default=0, description="Total memory count at refresh time")
- mission_hash: str = Field(default="", description="Hash of bank mission text")
- disposition_hash: str = Field(default="", description="Hash of bank disposition values")
- directives_hash: str = Field(default="", description="Hash of all directive observations")
-
-
-class RefreshCheckResult(BaseModel):
- """Result of checking if a mental model needs refresh."""
-
- needs_refresh: bool = Field(description="Whether a refresh is needed")
- reasons: list[str] = Field(default_factory=list, description="Reasons why refresh is needed")
- current_state: RefreshState | None = Field(default=None, description="Current state for comparison")
-
-
-def _hash_string(s: str) -> str:
- """Create a short hash of a string."""
- if not s:
- return ""
- return hashlib.sha256(s.encode()).hexdigest()[:16]
-
-
-def _hash_mission(mission: str | None) -> str:
- """Hash the bank mission text."""
- return _hash_string(mission or "")
-
-
-def _hash_disposition(disposition: DispositionTraits) -> str:
- """Hash the bank disposition values."""
- # Create deterministic string from disposition values
- return _hash_string(
- f"skepticism:{disposition.skepticism}|literalism:{disposition.literalism}|empathy:{disposition.empathy}"
- )
-
-
-def _hash_directives(directives: list[DirectiveMentalModel]) -> str:
- """Hash all directive observations."""
- if not directives:
- return ""
-
- # Create deterministic string from all directive observations
- parts = []
- for directive in sorted(directives, key=lambda d: d.id or d.name):
- directive_id = directive.id or directive.name
- for obs in directive.observations:
- parts.append(f"{directive_id}:{obs.title}:{obs.effective_content}")
-
- return _hash_string("|".join(parts))
-
-
-def compute_refresh_state(
- memories_count: int,
- bank_profile: BankProfile,
- directives: list[DirectiveMentalModel],
-) -> RefreshState:
- """Compute the current refresh state from inputs.
-
- Args:
- memories_count: Total number of memories in the bank
- bank_profile: Bank profile with mission and disposition
- directives: List of directive mental models
- """
- return RefreshState(
- last_refresh_at=datetime.now(timezone.utc).isoformat(),
- memories_count=memories_count,
- mission_hash=_hash_mission(bank_profile.mission),
- disposition_hash=_hash_disposition(bank_profile.disposition),
- directives_hash=_hash_directives(directives),
- )
-
-
-def check_needs_refresh(
- stored_state: dict | RefreshState | None,
- current_memories_count: int,
- bank_profile: BankProfile,
- directives: list[DirectiveMentalModel],
-) -> RefreshCheckResult:
- """Check if a mental model needs refresh by comparing states.
-
- Args:
- stored_state: Previously stored refresh state (or None if never refreshed)
- current_memories_count: Current total memory count
- bank_profile: Current bank profile
- directives: Current directive mental models
-
- Returns:
- RefreshCheckResult with needs_refresh flag and reasons
- """
- # Compute current state
- current_state = compute_refresh_state(current_memories_count, bank_profile, directives)
-
- # Never refreshed = definitely needs refresh
- if stored_state is None:
- return RefreshCheckResult(
- needs_refresh=True,
- reasons=["never_refreshed"],
- current_state=current_state,
- )
-
- # Parse stored state if dict
- if isinstance(stored_state, dict):
- try:
- stored = RefreshState.model_validate(stored_state)
- except Exception:
- return RefreshCheckResult(
- needs_refresh=True,
- reasons=["invalid_stored_state"],
- current_state=current_state,
- )
- else:
- stored = stored_state
-
- # Compare states
- reasons: list[str] = []
-
- if current_memories_count > stored.memories_count:
- reasons.append("new_memories")
-
- if current_state.mission_hash != stored.mission_hash:
- reasons.append("mission_changed")
-
- if current_state.disposition_hash != stored.disposition_hash:
- reasons.append("disposition_changed")
-
- if current_state.directives_hash != stored.directives_hash:
- reasons.append("directives_changed")
-
- return RefreshCheckResult(
- needs_refresh=len(reasons) > 0,
- reasons=reasons,
- current_state=current_state,
- )
-
-
-class PhaseTokenUsage(BaseModel):
- """Token usage from a phase's LLM calls."""
-
- input_tokens: int = Field(default=0)
- output_tokens: int = Field(default=0)
- total_tokens: int = Field(default=0)
-
- def __add__(self, other: "PhaseTokenUsage") -> "PhaseTokenUsage":
- """Allow aggregating token usage."""
- return PhaseTokenUsage(
- input_tokens=self.input_tokens + other.input_tokens,
- output_tokens=self.output_tokens + other.output_tokens,
- total_tokens=self.total_tokens + other.total_tokens,
- )
-
-
-class SeedPhaseResult(BaseModel):
- """Result from the seed phase."""
-
- candidates: list[CandidateObservation] = Field(default_factory=list)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class UpdateExistingResult(BaseModel):
- """Result from the update existing phase."""
-
- updated_observations: list[dict] = Field(default_factory=list)
- contradicted_titles: list[str] = Field(default_factory=list)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class ValidatePhaseResult(BaseModel):
- """Result from the validate phase."""
-
- verified_observations: list[dict] = Field(default_factory=list)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class ComparePhaseResult(BaseModel):
- """Result from the compare phase."""
-
- observations: list[Observation] = Field(default_factory=list)
- changes: dict = Field(default_factory=dict)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class MentalModelReflectResult(BaseModel):
- """Result from the mental model reflect process."""
-
- observations: list[Observation] = Field(default_factory=list, description="Final validated observations")
- version: int = Field(default=1, description="New version number for this mental model")
- changes: dict = Field(default_factory=dict, description="Summary of changes made")
- phases_completed: list[str] = Field(default_factory=list, description="Which phases were completed")
- duration_ms: int = Field(default=0, description="Total duration in milliseconds")
- memories_analyzed: int = Field(default=0, description="Number of memories analyzed")
- candidates_generated: int = Field(default=0, description="Number of candidate observations generated")
- candidates_validated: int = Field(default=0, description="Number of candidates that passed validation")
- # Token usage tracking
- input_tokens: int = Field(default=0, description="Total input tokens used across all LLM calls")
- output_tokens: int = Field(default=0, description="Total output tokens used across all LLM calls")
- total_tokens: int = Field(default=0, description="Total tokens used (input + output)")
-
-
-class SeedPhaseOutput(BaseModel):
- """Output from the seed phase."""
-
- candidates: list[CandidateObservation] = Field(default_factory=list)
-
-
-class ValidatePhaseOutput(BaseModel):
- """Output from the validate phase."""
-
- observations: list[dict] = Field(default_factory=list)
- discarded: list[dict] = Field(default_factory=list)
- merged: list[dict] = Field(default_factory=list)
-
-
-class ComparePhaseOutput(BaseModel):
- """Output from the compare phase."""
-
- observations: list[dict] = Field(default_factory=list)
- changes: dict = Field(default_factory=dict)
-
-
-class NewEvidenceItem(BaseModel):
- """A new evidence item from the update existing phase."""
-
- memory_id: str = Field(description="ID of the memory this quote is from")
- quote: str = Field(description="Exact quote from the memory")
- relevance: str = Field(default="", description="Why this quote supports the observation")
- timestamp: str = Field(default="", description="When the memory was created (ISO format)")
-
-
-class UpdatedObservation(BaseModel):
- """Output for a single updated observation."""
-
- title: str = Field(default="")
- content: str = Field(default="")
- existing_evidence_count: int = Field(default=0)
- new_evidence: list[NewEvidenceItem] = Field(default_factory=list)
- has_contradiction: bool = Field(default=False)
- contradiction_note: str | None = Field(default=None)
-
-
-class UpdateExistingPhaseOutput(BaseModel):
- """Output from the update existing phase."""
-
- updated_observations: list[UpdatedObservation] = Field(default_factory=list)
-
-
-class ObservationWithEvidence(BaseModel):
- """An existing observation with newly found evidence for the update phase."""
-
- observation: dict = Field(description="The original observation dict")
- supporting_memories: list[dict] = Field(default_factory=list, description="Newly found supporting memories")
- contradicting_memories: list[dict] = Field(default_factory=list, description="Newly found contradicting memories")
-
-
-async def run_mental_model_reflect(
- llm_config: "LLMProvider",
- bank_id: str,
- mental_model_id: str,
- mental_model_name: str,
- existing_observations: list[dict],
- current_version: int,
- get_diverse_memories_fn: Callable[[], Awaitable[list[dict]]],
- recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
- topic: str | None = None,
- max_candidates: int = 15,
-) -> MentalModelReflectResult:
- """
- Execute the diff-based mental model reflect loop.
-
- This is a 5-phase process that efficiently updates existing observations
- and discovers new patterns without redundant work:
-
- Phase 0: UPDATE EXISTING - Search for new evidence for existing observations
- Phase 1: SEED - Generate NEW candidate observations (skipping already-tracked patterns)
- Phase 2: EVIDENCE HUNT - Search for evidence for new candidates
- Phase 3: VALIDATE - Validate new candidates and extract quotes
- Phase 4: COMPARE - Merge updated existing + new validated observations
-
- Args:
- llm_config: LLM provider for agent calls
- bank_id: Bank identifier
- mental_model_id: ID of the mental model being updated
- mental_model_name: Name of the mental model (for context)
- existing_observations: Current observations in the mental model
- current_version: Current version number of the mental model
- get_diverse_memories_fn: Async function to get diverse memory sample
- recall_fn: Async function for semantic search (query, max_tokens) -> memories
- topic: Optional topic focus for the mental model
- max_candidates: Maximum number of candidate observations to generate
-
- Returns:
- MentalModelReflectResult with final observations and metadata
- """
- reflect_id = f"mm-{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
- start_time = time.time()
- phases_completed: list[str] = []
- updated_existing: list[dict] = []
- contradicted_observations: list[str] = []
- total_usage = PhaseTokenUsage() # Aggregate token usage across all phases
-
- logger.info(
- f"[MM-REFLECT {reflect_id}] Starting diff-based reflect for mental model '{mental_model_name}' "
- f"({len(existing_observations)} existing observations)"
- )
-
- # ==========================================================================
- # PHASE 0: UPDATE EXISTING (if there are existing observations)
- # ==========================================================================
- if existing_observations:
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 0: UPDATE EXISTING - Searching for new evidence")
- phase0_start = time.time()
-
- update_result = await _run_update_existing_phase(
- llm_config=llm_config,
- existing_observations=existing_observations,
- recall_fn=recall_fn,
- reflect_id=reflect_id,
- )
- updated_existing = update_result.updated_observations
- contradicted_observations = update_result.contradicted_titles
- total_usage = total_usage + update_result.token_usage
-
- phase0_duration = int((time.time() - phase0_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 0 complete: "
- f"{len(updated_existing)} observations updated, "
- f"{len(contradicted_observations)} flagged for removal ({phase0_duration}ms)"
- )
- phases_completed.append("update_existing")
-
- # ==========================================================================
- # PHASE 1: SEED (with existing observations context)
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 1: SEED - Getting diverse memories")
- phase1_start = time.time()
-
- # Get diverse memory sample
- seed_memories = await get_diverse_memories_fn()
- if not seed_memories:
- logger.warning(f"[MM-REFLECT {reflect_id}] No memories found for seeding")
- # Return updated existing observations if we have them
- if updated_existing:
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in updated_existing],
- version=current_version + 1,
- changes={
- "note": "Updated existing observations, no new patterns found",
- "updated": len(updated_existing),
- },
- phases_completed=phases_completed + ["seed_empty"],
- duration_ms=int((time.time() - start_time) * 1000),
- )
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in existing_observations],
- version=current_version + 1, # Always increment version when refresh runs
- changes={"note": "No memories available for analysis"},
- phases_completed=["seed_empty"],
- duration_ms=int((time.time() - start_time) * 1000),
- )
-
- # Generate NEW candidate observations (pass existing to avoid rediscovering them)
- seed_result = await _run_seed_phase(
- llm_config=llm_config,
- memories=seed_memories,
- topic=topic or mental_model_name,
- max_candidates=max_candidates,
- reflect_id=reflect_id,
- existing_observations=existing_observations, # Pass existing to skip them
- )
- candidates = seed_result.candidates
- total_usage = total_usage + seed_result.token_usage
-
- phase1_duration = int((time.time() - phase1_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 1 complete: {len(candidates)} NEW candidates from {len(seed_memories)} memories ({phase1_duration}ms)"
- )
- phases_completed.append("seed")
-
- # If no new candidates but we updated existing, return those
- if not candidates and updated_existing:
- logger.info(f"[MM-REFLECT {reflect_id}] No new patterns found, returning updated existing observations")
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in updated_existing],
- version=current_version + 1,
- changes={
- "note": "Updated existing observations with new evidence, no new patterns discovered",
- "updated": len(updated_existing),
- "contradicted": contradicted_observations,
- },
- phases_completed=phases_completed,
- duration_ms=int((time.time() - start_time) * 1000),
- memories_analyzed=len(seed_memories),
- input_tokens=total_usage.input_tokens,
- output_tokens=total_usage.output_tokens,
- total_tokens=total_usage.total_tokens,
- )
-
- # If no candidates and no existing, return empty
- if not candidates:
- logger.warning(f"[MM-REFLECT {reflect_id}] No candidates generated in seed phase")
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in existing_observations],
- version=current_version + 1, # Always increment version when refresh runs
- changes={"note": "No candidate observations could be generated"},
- phases_completed=phases_completed,
- duration_ms=int((time.time() - start_time) * 1000),
- memories_analyzed=len(seed_memories),
- input_tokens=total_usage.input_tokens,
- output_tokens=total_usage.output_tokens,
- total_tokens=total_usage.total_tokens,
- )
-
- # ==========================================================================
- # PHASE 2: EVIDENCE HUNT (for new candidates only)
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 2: EVIDENCE HUNT - Searching for evidence")
- phase2_start = time.time()
-
- candidates_with_evidence = await _run_evidence_hunt_phase(
- candidates=candidates,
- recall_fn=recall_fn,
- reflect_id=reflect_id,
- )
-
- phase2_duration = int((time.time() - phase2_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 2 complete: Evidence gathered for {len(candidates_with_evidence)} candidates ({phase2_duration}ms)"
- )
- phases_completed.append("evidence_hunt")
-
- # ==========================================================================
- # PHASE 3: VALIDATE & REFINE (for new candidates only)
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 3: VALIDATE - Validating candidates")
- phase3_start = time.time()
-
- validate_result = await _run_validate_phase(
- llm_config=llm_config,
- candidates_with_evidence=candidates_with_evidence,
- reflect_id=reflect_id,
- )
- validated_observations = validate_result.verified_observations
- total_usage = total_usage + validate_result.token_usage
-
- phase3_duration = int((time.time() - phase3_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 3 complete: {len(validated_observations)} observations validated ({phase3_duration}ms)"
- )
- phases_completed.append("validate")
-
- # ==========================================================================
- # PHASE 4: COMPARE & MERGE
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 4: COMPARE - Merging updated existing + new observations")
- phase4_start = time.time()
-
- # Use updated existing (with new evidence) instead of original existing
- observations_for_compare = updated_existing if updated_existing else existing_observations
-
- compare_result = await _run_compare_phase(
- llm_config=llm_config,
- existing_observations=observations_for_compare,
- new_observations=validated_observations,
- reflect_id=reflect_id,
- )
- final_observations = compare_result.observations
- changes = compare_result.changes
- total_usage = total_usage + compare_result.token_usage
-
- # Add contradiction info to changes
- if contradicted_observations:
- changes["contradicted"] = contradicted_observations
-
- phase4_duration = int((time.time() - phase4_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 4 complete: {len(final_observations)} final observations ({phase4_duration}ms)"
- )
- phases_completed.append("compare")
-
- # ==========================================================================
- # FINALIZE
- # ==========================================================================
- total_duration = int((time.time() - start_time) * 1000)
- new_version = current_version + 1
-
- logger.info(
- f"[MM-REFLECT {reflect_id}] Complete: "
- f"v{current_version}→v{new_version}, "
- f"{len(final_observations)} observations, "
- f"{total_duration}ms total"
- )
-
- return MentalModelReflectResult(
- observations=final_observations,
- version=new_version,
- changes=changes,
- phases_completed=phases_completed,
- duration_ms=total_duration,
- memories_analyzed=len(seed_memories),
- candidates_generated=len(candidates),
- candidates_validated=len(validated_observations),
- input_tokens=total_usage.input_tokens,
- output_tokens=total_usage.output_tokens,
- total_tokens=total_usage.total_tokens,
- )
-
-
-async def _run_seed_phase(
- llm_config: "LLMProvider",
- memories: list[dict],
- topic: str,
- max_candidates: int,
- reflect_id: str,
- existing_observations: list[dict] | None = None,
-) -> SeedPhaseResult:
- """Phase 1: Generate NEW candidate observations from diverse memories.
-
- If existing_observations are provided, the LLM will be instructed to skip
- patterns that are already tracked, focusing only on genuinely new discoveries.
- """
- prompt = build_seed_phase_prompt(memories, topic, existing_observations)
-
- try:
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": SEED_PHASE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=SeedPhaseOutput,
- scope="mm_reflect_seed",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response
- if hasattr(response, "candidates"):
- candidates = response.candidates[:max_candidates]
- elif isinstance(response, dict) and "candidates" in response:
- candidates = [
- CandidateObservation(
- content=c.get("content", ""),
- seed_memory_ids=c.get("seed_memory_ids", []),
- )
- for c in response["candidates"][:max_candidates]
- ]
- else:
- # Try to parse as JSON
- try:
- data = json.loads(str(response))
- candidates = [
- CandidateObservation(
- content=c.get("content", ""),
- seed_memory_ids=c.get("seed_memory_ids", []),
- )
- for c in data.get("candidates", [])[:max_candidates]
- ]
- except (json.JSONDecodeError, TypeError):
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse seed phase response")
- candidates = []
-
- return SeedPhaseResult(candidates=candidates, token_usage=usage)
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Seed phase failed: {e}")
- return SeedPhaseResult()
-
-
-def _parse_update_existing_response(response: Any, reflect_id: str) -> UpdateExistingPhaseOutput:
- """Parse LLM response into UpdateExistingPhaseOutput.
-
- Handles multiple response formats: Pydantic model, dict, or JSON string.
- """
- # Already a Pydantic model
- if isinstance(response, UpdateExistingPhaseOutput):
- return response
-
- # Dict response - validate and convert
- if isinstance(response, dict):
- try:
- return UpdateExistingPhaseOutput.model_validate(response)
- except Exception as e:
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to validate dict response: {e}")
- return UpdateExistingPhaseOutput()
-
- # String response - try to parse as JSON
- try:
- data = json.loads(str(response))
- return UpdateExistingPhaseOutput.model_validate(data)
- except (json.JSONDecodeError, TypeError, Exception) as e:
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse update existing response: {e}")
- return UpdateExistingPhaseOutput()
-
-
-async def _run_update_existing_phase(
- llm_config: "LLMProvider",
- existing_observations: list[dict],
- recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
- reflect_id: str,
-) -> UpdateExistingResult:
- """Phase 0: Search for new evidence for existing observations.
-
- For each existing observation:
- 1. Search for new supporting evidence
- 2. Search for contradicting evidence
- 3. Extract new quotes and add to the observation
- 4. Flag observations with strong contradictions
- """
- if not existing_observations:
- return UpdateExistingResult()
-
- # Search for evidence for all existing observations in parallel
- async def search_evidence_for_observation(obs: dict) -> ObservationWithEvidence:
- content = obs.get("content", "")
- existing_evidence = obs.get("evidence", [])
- existing_memory_ids = {e.get("memory_id") for e in existing_evidence if isinstance(e, dict)}
-
- # Search for supporting and contradicting evidence
- supporting_query = f"evidence supporting: {content}"
- contradicting_query = f"evidence against: {content}"
-
- supporting_result, contradicting_result = await asyncio.gather(
- recall_fn(supporting_query, 2048),
- recall_fn(contradicting_query, 2048),
- return_exceptions=True,
- )
-
- supporting_memories: list[dict] = []
- contradicting_memories: list[dict] = []
-
- if isinstance(supporting_result, dict) and "memories" in supporting_result:
- # Filter out memories we already have evidence from
- supporting_memories = [
- m
- for m in supporting_result["memories"]
- if isinstance(m, dict) and m.get("id") not in existing_memory_ids
- ]
- if isinstance(contradicting_result, dict) and "memories" in contradicting_result:
- contradicting_memories = [m for m in contradicting_result["memories"] if isinstance(m, dict)]
-
- return ObservationWithEvidence(
- observation=obs,
- supporting_memories=supporting_memories,
- contradicting_memories=contradicting_memories,
- )
-
- # Run all searches in parallel
- tasks = [search_evidence_for_observation(obs) for obs in existing_observations]
- search_results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Filter out exceptions and collect valid results
- valid_results: list[ObservationWithEvidence] = [r for r in search_results if isinstance(r, ObservationWithEvidence)]
-
- # Log any errors
- errors = [r for r in search_results if isinstance(r, Exception)]
- if errors:
- logger.warning(f"[MM-REFLECT {reflect_id}] {len(errors)} evidence search errors: {errors[:3]}")
-
- # If no new evidence found for any observation, return originals unchanged
- has_new_evidence = any(result.supporting_memories or result.contradicting_memories for result in valid_results)
-
- if not has_new_evidence:
- logger.info(f"[MM-REFLECT {reflect_id}] No new evidence found for existing observations")
- return UpdateExistingResult(updated_observations=existing_observations)
-
- # Build prompt data for LLM
- prompt_data = [
- {
- "observation": result.observation,
- "supporting_memories": result.supporting_memories,
- "contradicting_memories": result.contradicting_memories,
- }
- for result in valid_results
- ]
-
- # Call LLM to extract quotes from new evidence
- try:
- prompt = build_update_existing_prompt(prompt_data)
-
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": UPDATE_EXISTING_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=UpdateExistingPhaseOutput,
- scope="mm_reflect_update_existing",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response into typed model
- parsed_response = _parse_update_existing_response(response, reflect_id)
-
- if not parsed_response.updated_observations:
- logger.info(f"[MM-REFLECT {reflect_id}] No updated observations in LLM response")
- return UpdateExistingResult(updated_observations=existing_observations, token_usage=usage)
-
- # Build memory content map for quote verification
- memory_content_map: dict[str, str] = {}
- for result in valid_results:
- for mem in result.supporting_memories + result.contradicting_memories:
- mem_id = mem.get("id", "")
- mem_content = mem.get("content", mem.get("text", ""))
- if mem_id and mem_content:
- memory_content_map[mem_id] = mem_content
-
- # Process updated observations
- updated_observations: list[dict] = []
- contradicted_titles: list[str] = []
- total_new_evidence = 0
-
- for i, updated in enumerate(parsed_response.updated_observations):
- if i >= len(existing_observations):
- break
-
- original_obs = existing_observations[i]
-
- # Check for contradiction
- if updated.has_contradiction:
- title = original_obs.get("title", f"Observation {i + 1}")
- contradicted_titles.append(title)
- logger.info(f"[MM-REFLECT {reflect_id}] Observation '{title}' flagged for contradiction")
-
- # Verify and add new evidence
- existing_evidence = original_obs.get("evidence", [])
- verified_new_evidence: list[dict] = []
-
- for ev in updated.new_evidence:
- memory_content = memory_content_map.get(ev.memory_id, "")
-
- # Verify quote exists in memory
- if (
- ev.quote
- and memory_content
- and (ev.quote in memory_content or _fuzzy_quote_match(ev.quote, memory_content))
- ):
- verified_new_evidence.append(
- {
- "memory_id": ev.memory_id,
- "quote": ev.quote,
- "relevance": ev.relevance,
- "timestamp": ev.timestamp,
- }
- )
-
- total_new_evidence += len(verified_new_evidence)
-
- # Merge existing and new evidence
- merged_evidence = existing_evidence + verified_new_evidence
-
- # Create updated observation
- updated_obs = {
- **original_obs,
- "evidence": merged_evidence,
- }
- updated_observations.append(updated_obs)
-
- # For any observations not in the response, keep them unchanged
- for i in range(len(parsed_response.updated_observations), len(existing_observations)):
- updated_observations.append(existing_observations[i])
-
- logger.info(
- f"[MM-REFLECT {reflect_id}] Updated {len(updated_observations)} observations, "
- f"added {total_new_evidence} new evidence items"
- )
-
- return UpdateExistingResult(
- updated_observations=updated_observations,
- contradicted_titles=contradicted_titles,
- token_usage=usage,
- )
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Update existing phase failed: {e}", exc_info=True)
- return UpdateExistingResult(updated_observations=existing_observations)
-
-
-async def _run_evidence_hunt_phase(
- candidates: list[CandidateObservation],
- recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
- reflect_id: str,
-) -> list[CandidateWithEvidence]:
- """Phase 2: For each candidate, search for supporting and contradicting evidence."""
- results: list[CandidateWithEvidence] = []
-
- # Run evidence searches in parallel for all candidates
- async def search_evidence_for_candidate(candidate: CandidateObservation) -> CandidateWithEvidence:
- # Search for supporting evidence
- supporting_query = f"evidence supporting: {candidate.content}"
- contradicting_query = f"evidence against: {candidate.content}"
-
- supporting_result, contradicting_result = await asyncio.gather(
- recall_fn(supporting_query, 2048),
- recall_fn(contradicting_query, 2048),
- return_exceptions=True,
- )
-
- supporting_memories = []
- contradicting_memories = []
-
- if isinstance(supporting_result, dict) and "memories" in supporting_result:
- supporting_memories = supporting_result["memories"]
- if isinstance(contradicting_result, dict) and "memories" in contradicting_result:
- contradicting_memories = contradicting_result["memories"]
-
- return CandidateWithEvidence(
- candidate=candidate,
- supporting_memories=supporting_memories,
- contradicting_memories=contradicting_memories,
- )
-
- # Run all searches in parallel
- tasks = [search_evidence_for_candidate(c) for c in candidates]
- gather_results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Filter out exceptions
- valid_results: list[CandidateWithEvidence] = [r for r in gather_results if isinstance(r, CandidateWithEvidence)]
-
- logger.info(f"[MM-REFLECT {reflect_id}] Evidence hunt: {len(valid_results)}/{len(candidates)} candidates processed")
- return valid_results
-
-
-async def _run_validate_phase(
- llm_config: "LLMProvider",
- candidates_with_evidence: list[CandidateWithEvidence],
- reflect_id: str,
-) -> ValidatePhaseResult:
- """Phase 3: Validate candidates and extract exact quotes."""
- # Convert to dict format for prompt
- candidates_data = [
- {
- "candidate": {
- "content": c.candidate.content,
- },
- "supporting_memories": c.supporting_memories,
- "contradicting_memories": c.contradicting_memories,
- }
- for c in candidates_with_evidence
- ]
-
- prompt = build_validate_phase_prompt(candidates_data)
-
- try:
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": VALIDATE_PHASE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=ValidatePhaseOutput,
- scope="mm_reflect_validate",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response
- if hasattr(response, "observations"):
- observations = response.observations
- elif isinstance(response, dict) and "observations" in response:
- observations = response["observations"]
- else:
- # Try to parse as JSON
- try:
- data = json.loads(str(response))
- observations = data.get("observations", [])
- except (json.JSONDecodeError, TypeError):
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse validate phase response")
- observations = []
-
- # Build memory content map for quote verification
- memory_content_map: dict[str, str] = {}
- for cwe in candidates_with_evidence:
- for mem in cwe.supporting_memories + cwe.contradicting_memories:
- mem_id = mem.get("id", "")
- content = mem.get("content", mem.get("text", ""))
- if mem_id and content:
- memory_content_map[mem_id] = content
-
- # Verify quotes in observations
- verified_observations = []
- for obs in observations:
- evidence = obs.get("evidence", [])
- verified_evidence = []
-
- for ev in evidence:
- mem_id = ev.get("memory_id", "")
- quote = ev.get("quote", "")
- memory_content = memory_content_map.get(mem_id, "")
-
- # Check if quote exists in memory (allow partial match for flexibility)
- if quote and memory_content and (quote in memory_content or _fuzzy_quote_match(quote, memory_content)):
- verified_evidence.append(ev)
- else:
- logger.debug(f"[MM-REFLECT {reflect_id}] Quote verification failed for memory {mem_id}")
-
- if verified_evidence:
- obs["evidence"] = verified_evidence
- verified_observations.append(obs)
- else:
- logger.debug(
- f"[MM-REFLECT {reflect_id}] Observation discarded - no verified evidence: {obs.get('content', '')[:50]}"
- )
-
- return ValidatePhaseResult(verified_observations=verified_observations, token_usage=usage)
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Validate phase failed: {e}")
- return ValidatePhaseResult()
-
-
-def _fuzzy_quote_match(quote: str, content: str, threshold: float = 0.8) -> bool:
- """Check if a quote roughly matches content (handles minor LLM variations)."""
- # Normalize both strings
- quote_words = set(quote.lower().split())
- content_words = set(content.lower().split())
-
- if not quote_words:
- return False
-
- # Check word overlap
- overlap = len(quote_words & content_words)
- similarity = overlap / len(quote_words)
-
- return similarity >= threshold
-
-
-async def _run_compare_phase(
- llm_config: "LLMProvider",
- existing_observations: list[dict],
- new_observations: list[dict],
- reflect_id: str,
-) -> ComparePhaseResult:
- """Phase 4: Merge new observations with existing mental model."""
- # If no existing observations, just convert new ones
- if not existing_observations:
- final_obs = [_dict_to_observation(obs) for obs in new_observations]
- changes = {
- "added": [obs.get("content", "") for obs in new_observations],
- "kept": [],
- "updated": [],
- "removed": [],
- "merged": [],
- }
- return ComparePhaseResult(observations=final_obs, changes=changes)
-
- # If no new observations, keep existing
- if not new_observations:
- final_obs = [_dict_to_observation(obs) for obs in existing_observations]
- changes = {
- "added": [],
- "kept": [obs.get("content", obs.get("text", "")) for obs in existing_observations],
- "updated": [],
- "removed": [],
- "merged": [],
- }
- return ComparePhaseResult(observations=final_obs, changes=changes)
-
- prompt = build_compare_phase_prompt(existing_observations, new_observations)
-
- try:
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": COMPARE_PHASE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=ComparePhaseOutput,
- scope="mm_reflect_compare",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response
- if hasattr(response, "observations"):
- observations_data = response.observations
- changes = response.changes if hasattr(response, "changes") else {}
- elif isinstance(response, dict):
- observations_data = response.get("observations", [])
- changes = response.get("changes", {})
- else:
- # Try to parse as JSON
- try:
- data = json.loads(str(response))
- observations_data = data.get("observations", [])
- changes = data.get("changes", {})
- except (json.JSONDecodeError, TypeError):
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse compare phase response")
- # Fallback: just use new observations
- observations_data = new_observations
- changes = {"note": "Compare phase parsing failed, using new observations"}
-
- # Convert to Observation objects
- final_observations = [_dict_to_observation(obs) for obs in observations_data]
- return ComparePhaseResult(observations=final_observations, changes=changes, token_usage=usage)
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Compare phase failed: {e}")
- # Fallback: merge by keeping all
- all_obs = existing_observations + new_observations
- final_obs = [_dict_to_observation(obs) for obs in all_obs]
- changes = {"note": f"Compare phase failed: {e}, keeping all observations"}
- return ComparePhaseResult(observations=final_obs, changes=changes)
-
-
-def _dict_to_observation(data: dict) -> Observation:
- """Convert a dict to an Observation model."""
- # Handle both new format (title, content, evidence) and legacy format (title, text, memory_ids)
- title = data.get("title", "")
- content = data.get("content", "")
-
- if not content:
- # Legacy format: use text as content
- text = data.get("text", "")
- content = text
-
- if not title:
- # Generate title from content (first ~50 chars)
- title = content[:50].strip() + ("..." if len(content) > 50 else "")
-
- # Parse evidence
- evidence: list[ObservationEvidence] = []
- evidence_data = data.get("evidence", [])
-
- if evidence_data:
- for ev in evidence_data:
- try:
- timestamp = ev.get("timestamp")
- if isinstance(timestamp, str):
- timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
- elif timestamp is None:
- timestamp = datetime.now(timezone.utc)
-
- evidence.append(
- ObservationEvidence(
- memory_id=ev.get("memory_id", ""),
- quote=ev.get("quote", ""),
- relevance=ev.get("relevance", ""),
- timestamp=timestamp,
- )
- )
- except Exception:
- pass
- else:
- # Legacy format: memory_ids without quotes
- memory_ids = data.get("memory_ids", []) or data.get("fact_ids", [])
- for mid in memory_ids:
- evidence.append(
- ObservationEvidence(
- memory_id=mid,
- quote="[migrated - quote not available]",
- relevance="[migrated]",
- timestamp=datetime.now(timezone.utc),
- )
- )
-
- # Parse created_at
- created_at = data.get("created_at")
- if isinstance(created_at, str):
- try:
- created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
- except ValueError:
- created_at = datetime.now(timezone.utc)
- elif created_at is None:
- created_at = datetime.now(timezone.utc)
-
- return Observation(
- title=title,
- content=content,
- evidence=evidence,
- created_at=created_at,
- )
diff --git a/hindsight-api/hindsight_api/engine/reflect/models.py b/hindsight-api/hindsight_api/engine/reflect/models.py
index 02a16868..71e6d044 100644
--- a/hindsight-api/hindsight_api/engine/reflect/models.py
+++ b/hindsight-api/hindsight_api/engine/reflect/models.py
@@ -104,11 +104,15 @@ class ReflectAgentResult(BaseModel):
)
iterations: int = Field(default=0, description="Number of iterations taken")
tools_called: int = Field(default=0, description="Total number of tool calls made")
- mental_models_created: list[str] = Field(default_factory=list, description="IDs of mental models created/updated")
tool_trace: list[ToolCall] = Field(default_factory=list, description="Trace of all tool calls made")
llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made")
used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
- used_model_ids: list[str] = Field(default_factory=list, description="Validated model IDs actually used in answer")
+ used_reflection_ids: list[str] = Field(
+ default_factory=list, description="Validated reflection IDs actually used in answer"
+ )
+ used_mental_model_ids: list[str] = Field(
+ default_factory=list, description="Validated mental model IDs actually used in answer"
+ )
directives_applied: list[DirectiveInfo] = Field(
default_factory=list, description="Directive mental models that affected this reflection"
)
diff --git a/hindsight-api/hindsight_api/engine/reflect/observations.py b/hindsight-api/hindsight_api/engine/reflect/observations.py
index 67708b83..12035360 100644
--- a/hindsight-api/hindsight_api/engine/reflect/observations.py
+++ b/hindsight-api/hindsight_api/engine/reflect/observations.py
@@ -184,65 +184,3 @@ def compute_trend(
return Trend.WEAKENING
else:
return Trend.STABLE
-
-
-class CandidateObservation(BaseModel):
- """A candidate observation generated during the seed phase.
-
- Candidates are preliminary observations that need evidence validation
- before becoming full observations.
- """
-
- content: str = Field(description="The proposed observation content")
- seed_memory_ids: list[str] = Field(default_factory=list, description="Memory IDs that inspired this candidate")
-
-
-class CandidateWithEvidence(BaseModel):
- """A candidate observation with gathered supporting and contradicting evidence."""
-
- candidate: CandidateObservation
- supporting_memories: list[dict] = Field(default_factory=list, description="Memories that support this observation")
- contradicting_memories: list[dict] = Field(
- default_factory=list, description="Memories that contradict this observation"
- )
-
-
-class MentalModelSnapshot(BaseModel):
- """A versioned snapshot of a mental model's observations.
-
- Used for tracking changes over time and enabling diff views.
- """
-
- version: int = Field(description="Version number (1-indexed)")
- observations: list[Observation] = Field(default_factory=list, description="Observations at this version")
- created_at: datetime = Field(
- default_factory=lambda: datetime.now(timezone.utc), description="When this version was created"
- )
- reflect_summary: str | None = Field(default=None, description="Summary of changes in this version")
-
-
-def verify_evidence_quotes(
- observation: Observation,
- memories: dict[str, str],
-) -> tuple[bool, list[str]]:
- """Verify that all evidence quotes exist in the referenced memories.
-
- Args:
- observation: The observation to verify
- memories: Dict mapping memory_id to memory content
-
- Returns:
- Tuple of (is_valid, list of error messages)
- """
- errors = []
-
- for evidence in observation.evidence:
- memory_content = memories.get(evidence.memory_id)
- if memory_content is None:
- errors.append(f"Memory {evidence.memory_id} not found")
- continue
-
- if evidence.quote not in memory_content:
- errors.append(f"Quote not found in memory {evidence.memory_id}: '{evidence.quote[:50]}...'")
-
- return len(errors) == 0, errors
diff --git a/hindsight-api/hindsight_api/engine/reflect/prompts.py b/hindsight-api/hindsight_api/engine/reflect/prompts.py
index 8ff572d9..71099ed4 100644
--- a/hindsight-api/hindsight_api/engine/reflect/prompts.py
+++ b/hindsight-api/hindsight_api/engine/reflect/prompts.py
@@ -1,5 +1,10 @@
"""
System prompts for the reflect agent.
+
+The reflect agent uses hierarchical retrieval:
+1. search_reflections - User-curated summaries (highest quality)
+2. search_mental_models - Consolidated knowledge with freshness awareness
+3. recall - Raw facts as ground truth fallback
"""
import json
@@ -11,7 +16,7 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
Extract directive rules as a list of strings.
Args:
- directives: List of directive mental models with observations
+ directives: List of directives with name and content
Returns:
List of directive rule strings
@@ -19,25 +24,34 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
rules = []
for directive in directives:
directive_name = directive.get("name", "")
- observations = directive.get("observations", [])
- if observations:
- for obs in observations:
- # Support both Pydantic Observation objects and dicts
- if hasattr(obs, "title"):
- title = obs.title
- content = obs.content
- else:
- title = obs.get("title", "")
- content = obs.get("content", "")
- if title and content:
- rules.append(f"**{title}**: {content}")
- elif content:
- rules.append(content)
- elif directive_name:
- # Fallback to description if no observations
- desc = directive.get("description", "")
- if desc:
- rules.append(f"**{directive_name}**: {desc}")
+ # New format: directives have direct content field
+ content = directive.get("content", "")
+ if content:
+ if directive_name:
+ rules.append(f"**{directive_name}**: {content}")
+ else:
+ rules.append(content)
+ else:
+ # Legacy format: check for observations
+ observations = directive.get("observations", [])
+ if observations:
+ for obs in observations:
+ # Support both Pydantic Observation objects and dicts
+ if hasattr(obs, "title"):
+ title = obs.title
+ obs_content = obs.content
+ else:
+ title = obs.get("title", "")
+ obs_content = obs.get("content", "")
+ if title and obs_content:
+ rules.append(f"**{title}**: {obs_content}")
+ elif obs_content:
+ rules.append(obs_content)
+ elif directive_name:
+ # Fallback to description
+ desc = directive.get("description", "")
+ if desc:
+ rules.append(f"**{directive_name}**: {desc}")
return rules
@@ -111,24 +125,25 @@ def build_system_prompt_for_tools(
bank_profile: dict[str, Any],
context: str | None = None,
directives: list[dict[str, Any]] | None = None,
+ has_reflections: bool = False,
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
- This is a simplified prompt since tools are defined separately via the tools parameter.
+ The agent uses hierarchical retrieval:
+ 1. search_reflections - User-curated summaries (try first, if available)
+ 2. search_mental_models - Consolidated knowledge with freshness
+ 3. recall - Raw facts as ground truth
Args:
bank_profile: Bank profile with name and mission
context: Optional additional context
directives: Optional list of directive mental models to inject as hard rules
+ has_reflections: Whether the bank has any reflections (skip if not)
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
- no_info_rule = (
- "- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
- )
-
parts = []
# Inject directives at the VERY START for maximum prominence
@@ -147,8 +162,7 @@ def build_system_prompt_for_tools(
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
- "- You MUST call recall() before saying you don't have information",
- no_info_rule,
+ "- You MUST search before saying you don't have information",
"",
"## How to Reason",
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
@@ -156,7 +170,56 @@ def build_system_prompt_for_tools(
"- Be a thoughtful interpreter, not just a literal repeater",
"- When the exact answer isn't stated, use what IS stated to give the best answer",
"",
- "## Query Strategy (IMPORTANT)",
+ "## HIERARCHICAL RETRIEVAL STRATEGY",
+ "",
+ ]
+ )
+
+ # Build retrieval levels based on what's available
+ if has_reflections:
+ parts.extend(
+ [
+ "You have access to THREE levels of knowledge. Use them in this order:",
+ "",
+ "### 1. REFLECTIONS (search_reflections) - Try First",
+ "- User-curated summaries about specific topics",
+ "- HIGHEST quality - manually created and maintained",
+ "- If a relevant reflection exists and is FRESH, it may fully answer the question",
+ "- Check `is_stale` field - if stale, also verify with lower levels",
+ "",
+ "### 2. MENTAL MODELS (search_mental_models) - Second Priority",
+ "- Auto-consolidated knowledge from memories",
+ "- Check `is_stale` field - if stale, ALSO use recall() to verify",
+ "- Good for understanding patterns and summaries",
+ "",
+ "### 3. RAW FACTS (recall) - Ground Truth",
+ "- Individual memories (world facts and experiences)",
+ "- Use when: no reflections/models exist, they're stale, or you need specific details",
+ "- This is the source of truth that other levels are built from",
+ "",
+ ]
+ )
+ else:
+ parts.extend(
+ [
+ "You have access to TWO levels of knowledge. Use them in this order:",
+ "",
+ "### 1. MENTAL MODELS (search_mental_models) - Try First",
+ "- Auto-consolidated knowledge from memories",
+ "- Check `is_stale` field - if stale, ALSO use recall() to verify",
+ "- Good for understanding patterns and summaries",
+ "",
+ "### 2. RAW FACTS (recall) - Ground Truth",
+ "- Individual memories (world facts and experiences)",
+ "- Use when: no mental models exist, they're stale, or you need specific details",
+ "- This is the source of truth that mental models are built from",
+ "",
+ ]
+ )
+
+ parts.extend(
+ [
+ "## Query Strategy",
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
"",
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
@@ -164,44 +227,41 @@ def build_system_prompt_for_tools(
" 1. recall('lessons') - find all lesson-related memories",
" 2. recall('teaching sessions') - alternative phrasing",
" 3. recall('student progress') - find student-related memories",
- " 4. recall('topics taught') - find subject matter",
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
- "- Questions about patterns → search for the individual instances first",
- "- Questions comparing things → search for each thing separately",
- "- Questions about relationships → search for each party involved",
"",
"## Workflow",
]
)
- # Answer mode: include mental model lookup in workflow
+ if has_reflections:
+ parts.extend(
+ [
+ "1. First, try search_reflections() - check if a curated summary exists",
+ "2. If no reflection or it's stale, try search_mental_models() for consolidated knowledge",
+ "3. If mental models are stale OR you need specific details, use recall() for raw facts",
+ "4. Use expand() if you need more context on specific memories",
+ "5. When ready, call done() with your answer and supporting IDs",
+ ]
+ )
+ else:
+ parts.extend(
+ [
+ "1. First, try search_mental_models() - check for consolidated knowledge",
+ "2. If mental models are stale OR you need specific details, use recall() for raw facts",
+ "3. Use expand() if you need more context on specific memories",
+ "4. When ready, call done() with your answer and supporting IDs",
+ ]
+ )
+
parts.extend(
[
- "1. Review the pre-fetched mental models for relevant synthesized knowledge",
- "2. If relevant, call get_mental_model(model_id) for full observations",
- "3. DECOMPOSE the question into component searches (see Query Strategy above)",
- " - Identify entities and concepts in the question",
- " - Search for each separately with targeted queries",
- "4. Run multiple recall() calls - don't just echo the user's question",
- "5. Use expand() if you need more context on specific memories",
- "6. BEFORE answering: Check if any person/project/concept from the memories deserves a mental model - use learn() if so",
- "7. When ready, call done() with your answer and supporting memory_ids",
- "",
- "## When to Use learn() - IMPORTANT",
- "ACTIVELY look for opportunities to use learn() when you discover:",
- "- A person mentioned in 2+ memories who has no mental model yet",
- "- A project or concept the user asks about that has no mental model",
- "- A pattern or topic worth tracking for future questions",
- "",
- "DO NOT wait to be asked - proactively create models when you see the need.",
- "Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
- "- Put memory IDs ONLY in the memory_ids array parameter, not in the answer",
+ "- Put IDs ONLY in the memory_ids/reflection_ids/mental_model_ids arrays, not in the answer",
]
)
@@ -295,9 +355,10 @@ def build_agent_prompt(
else:
parts.append(
"\n## Instructions\n"
- "Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. "
- "If a relevant model exists, use get_mental_model(model_id) to get its observations. "
- "Then use recall(query) for specific details not covered by mental models."
+ "Start by searching for relevant information using the hierarchical retrieval strategy:\n"
+ "1. Try search_reflections() first for curated summaries\n"
+ "2. Try search_mental_models() for consolidated knowledge\n"
+ "3. Use recall() for specific details or to verify stale data"
)
return "\n".join(parts)
@@ -377,386 +438,3 @@ Your approach:
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
Do NOT fabricate information that has no basis in the retrieved data."""
-
-
-# =============================================================================
-# 4-Phase Mental Model Reflect Prompts
-# =============================================================================
-
-SEED_PHASE_SYSTEM_PROMPT = """You are analyzing memories to discover NEW patterns and generate candidate observations.
-
-Your task is to identify potential observations (beliefs, preferences, patterns, behaviors) that could be part of a mental model about this person/topic.
-
-## Important: Avoid Redundancy
-If existing observations are provided, DO NOT generate candidates that are essentially the same.
-Focus on discovering NEW patterns not already covered by existing observations.
-
-## Rules
-- Generate 5-15 candidate observations for NEW patterns only
-- Each candidate should be specific and testable (can be supported or contradicted by evidence)
-- Note which memory IDs inspired each candidate (these are seeds, not final evidence)
-- Focus on patterns that appear MULTIPLE TIMES across many memories - the more the better
-- The best candidates are ones you can find 10, 20, or even 50+ supporting memories for
-- Skip patterns that are already covered by existing observations
-
-## Output Format
-Return a JSON array of candidate observations:
-```json
-{
- "candidates": [
- {
- "content": "The specific observation/belief/pattern - be detailed and specific",
- "seed_memory_ids": ["memory_id_1", "memory_id_2", "memory_id_3"]
- }
- ]
-}
-```
-
-Focus on patterns that appear multiple times or have strong signals. Don't generate obvious or trivial observations.
-Prefer candidates with MORE seed memories - they're more likely to be real patterns.
-Return an empty candidates array if no genuinely new patterns are found."""
-
-
-def build_seed_phase_prompt(
- memories: list[dict],
- topic: str | None = None,
- existing_observations: list[dict] | None = None,
-) -> str:
- """Build the user prompt for the seed phase.
-
- Args:
- memories: List of memories to analyze
- topic: Optional topic focus for the mental model
- existing_observations: Optional list of existing observations to avoid rediscovering
- """
- parts = []
-
- if topic:
- parts.append(f"## Topic Focus\n{topic}\n")
-
- # Include existing observations so we don't rediscover them
- if existing_observations:
- parts.append("## Existing Observations (DO NOT regenerate these)")
- parts.append("These patterns are already tracked. Focus on discovering NEW patterns:\n")
- for i, obs in enumerate(existing_observations, 1):
- title = obs.get("title", "")
- content = obs.get("content", "")
- parts.append(f"{i}. **{title}**: {content}\n")
- parts.append("")
-
- parts.append("## Memories to Analyze")
- parts.append("Review these memories and identify patterns, preferences, beliefs, and behaviors:\n")
-
- for mem in memories:
- mem_id = mem.get("id", "unknown")
- content = mem.get("content", mem.get("text", ""))
- timestamp = mem.get("timestamp", mem.get("created_at", ""))
- parts.append(f"[{mem_id}] ({timestamp}): {content}\n")
-
- parts.append("\n## Instructions")
- if existing_observations:
- parts.append("Generate candidate observations for NEW patterns not already covered above.")
- parts.append("If all patterns are already covered by existing observations, return an empty candidates array.")
- else:
- parts.append("Generate candidate observations based on patterns you see in these memories.")
- parts.append("Look for: recurring themes, stated preferences, behavioral patterns, beliefs, values, goals.")
-
- return "\n".join(parts)
-
-
-VALIDATE_PHASE_SYSTEM_PROMPT = """You are validating candidate observations against evidence.
-
-For each candidate, you have:
-- Supporting memories (evidence FOR the observation)
-- Contradicting memories (evidence AGAINST the observation)
-
-## Your Task
-1. Evaluate each candidate based on the evidence
-2. For valid candidates, extract EXACT QUOTES from supporting memories
-3. Discard candidates with insufficient or contradicting evidence
-4. Merge similar candidates into single, refined observations
-
-## Rules for Quotes
-- Quotes must be EXACT text from the memory, not paraphrased
-- Each quote should directly support the observation
-- The MORE evidence quotes, the BETTER - don't limit yourself, include ALL relevant quotes (10, 20, 50+)
-- Observations with only 1-2 quotes are weak and should be discarded unless the evidence is exceptionally strong
-- Stronger observations have more supporting evidence - aim for comprehensive coverage
-
-## Output Format
-Return validated observations with evidence:
-```json
-{
- "observations": [
- {
- "title": "Short descriptive title (3-8 words) - like a headline",
- "content": "The full observation content - detailed explanation of the pattern/belief",
- "evidence": [
- {
- "memory_id": "exact_memory_id",
- "quote": "Exact quote from the memory text",
- "relevance": "Brief explanation of how this supports the observation",
- "timestamp": "2024-01-15T10:00:00Z"
- }
- ]
- }
- ],
- "discarded": [
- {
- "content": "The discarded candidate",
- "reason": "Why it was discarded (insufficient evidence, contradicted, etc.)"
- }
- ],
- "merged": [
- {
- "from": ["candidate 1 content", "candidate 2 content"],
- "into": "The merged observation content"
- }
- ]
-}
-```
-
-## Title Guidelines
-- Title should be a SHORT label (like "Prefers morning meetings" or "Coffee enthusiast")
-- NOT a truncated version of the content
-- Think of it as a category/tag for the observation
-
-Be rigorous: only keep observations with clear, verifiable evidence from multiple memories."""
-
-
-def build_validate_phase_prompt(candidates_with_evidence: list[dict]) -> str:
- """Build the user prompt for the validate phase."""
- parts = ["## Candidates to Validate\n"]
-
- for i, item in enumerate(candidates_with_evidence, 1):
- candidate = item.get("candidate", {})
- supporting = item.get("supporting_memories", [])
- contradicting = item.get("contradicting_memories", [])
-
- parts.append(f"### Candidate {i}: {candidate.get('content', '')}")
-
- if supporting:
- parts.append("\n**Supporting Evidence:**")
- for mem in supporting:
- mem_id = mem.get("id", "unknown")
- content = mem.get("content", mem.get("text", ""))
- timestamp = mem.get("timestamp", mem.get("created_at", ""))
- parts.append(f"- [{mem_id}] ({timestamp}): {content}")
-
- if contradicting:
- parts.append("\n**Contradicting Evidence:**")
- for mem in contradicting:
- mem_id = mem.get("id", "unknown")
- content = mem.get("content", mem.get("text", ""))
- timestamp = mem.get("timestamp", mem.get("created_at", ""))
- parts.append(f"- [{mem_id}] ({timestamp}): {content}")
-
- if not supporting and not contradicting:
- parts.append("\n*No additional evidence found*")
-
- parts.append("")
-
- parts.append("## Instructions")
- parts.append("1. Evaluate each candidate based on its evidence")
- parts.append("2. Keep candidates with strong supporting evidence")
- parts.append("3. Discard candidates with no evidence or strong contradictions")
- parts.append("4. Merge similar candidates")
- parts.append("5. Extract EXACT quotes (copy-paste from memory text) for evidence")
-
- return "\n".join(parts)
-
-
-COMPARE_PHASE_SYSTEM_PROMPT = """You are merging new observations with an existing mental model.
-
-You have:
-- EXISTING observations (from the current mental model)
-- NEW observations (from this reflect cycle)
-
-## Your Task
-Produce the final, complete mental model by:
-1. Keeping existing observations that are still valid
-2. Updating existing observations with new evidence (ADD new evidence to existing)
-3. Adding new observations that don't overlap with existing
-4. Removing existing observations that are contradicted by new evidence
-5. Merging overlapping observations
-
-## Rules
-- The final model should have no contradictions
-- Each observation must have evidence with exact quotes
-- COMBINE evidence from both existing and new observations
-- If an existing observation has new supporting evidence, ADD ALL the new evidence to it
-- Include ALL relevant evidence - the more quotes the better (10, 20, 50+ is great)
-- Observations with more evidence are more reliable - don't limit the number of quotes
-
-## Output Format
-Return the complete, final mental model:
-```json
-{
- "observations": [
- {
- "title": "Short descriptive title (3-8 words)",
- "content": "Full observation content - detailed explanation",
- "evidence": [
- {
- "memory_id": "id",
- "quote": "exact quote",
- "relevance": "explanation",
- "timestamp": "ISO timestamp"
- }
- ],
- "created_at": "ISO timestamp of when observation was first created"
- }
- ],
- "changes": {
- "kept": ["Observation that was kept unchanged"],
- "updated": [{"from": "old content", "to": "new content", "reason": "why"}],
- "added": ["New observation that was added"],
- "removed": [{"content": "removed observation", "reason": "why removed"}],
- "merged": [{"from": ["obs1", "obs2"], "into": "merged observation"}]
- }
-}
-```"""
-
-
-def build_compare_phase_prompt(
- existing_observations: list[dict],
- new_observations: list[dict],
-) -> str:
- """Build the user prompt for the compare phase."""
- parts = []
-
- parts.append("## Existing Mental Model Observations")
- if existing_observations:
- for i, obs in enumerate(existing_observations, 1):
- title = obs.get("title", "")
- content = obs.get("content", obs.get("text", ""))
- evidence = obs.get("evidence", [])
- parts.append(f"\n### Existing {i}: {title}")
- parts.append(f"Content: {content}")
- if evidence:
- parts.append(f"Evidence ({len(evidence)} items):")
- for ev in evidence[:5]: # Show max 5 evidence items
- parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
- if len(evidence) > 5:
- parts.append(f" ... and {len(evidence) - 5} more")
- else:
- parts.append("*No existing observations*")
-
- parts.append("\n## New Observations from This Reflect")
- if new_observations:
- for i, obs in enumerate(new_observations, 1):
- title = obs.get("title", "")
- content = obs.get("content", "")
- evidence = obs.get("evidence", [])
- parts.append(f"\n### New {i}: {title}")
- parts.append(f"Content: {content}")
- if evidence:
- parts.append(f"Evidence ({len(evidence)} items):")
- for ev in evidence:
- parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
- else:
- parts.append("*No new observations*")
-
- parts.append("\n## Instructions")
- parts.append("Merge these into a coherent, non-contradictory mental model.")
- parts.append("Preserve all valid evidence. Remove stale or contradicted observations.")
-
- return "\n".join(parts)
-
-
-# =============================================================================
-# UPDATE EXISTING Phase Prompts (for diff-based refresh)
-# =============================================================================
-
-UPDATE_EXISTING_SYSTEM_PROMPT = """You are updating existing observations with newly found evidence.
-
-For each existing observation, you have been given:
-- The original observation (title, content, existing evidence)
-- Newly found supporting memories
-- Newly found contradicting memories
-
-## Your Task
-1. Extract EXACT QUOTES from new supporting memories to add to the observation
-2. Flag observations with strong contradicting evidence for potential removal
-3. Keep existing evidence intact - only ADD new evidence
-
-## Rules for Quotes
-- Quotes must be EXACT text from the memory, not paraphrased
-- Each quote should directly support the observation
-- Include ALL relevant quotes from the new memories
-
-## Output Format
-Return updated observations with new evidence:
-```json
-{
- "updated_observations": [
- {
- "title": "Original title",
- "content": "Original content",
- "existing_evidence_count": 5,
- "new_evidence": [
- {
- "memory_id": "exact_memory_id",
- "quote": "Exact quote from the memory text",
- "relevance": "Brief explanation of how this supports the observation",
- "timestamp": "2024-01-15T10:00:00Z"
- }
- ],
- "has_contradiction": false,
- "contradiction_note": null
- }
- ]
-}
-```
-
-If an observation has strong contradicting evidence, set has_contradiction=true and explain in contradiction_note."""
-
-
-def build_update_existing_prompt(observations_with_evidence: list[dict]) -> str:
- """Build the user prompt for the update existing phase.
-
- Args:
- observations_with_evidence: List of existing observations with new evidence found
- """
- parts = ["## Existing Observations to Update\n"]
-
- for i, item in enumerate(observations_with_evidence, 1):
- obs = item.get("observation", {})
- supporting = item.get("supporting_memories", [])
- contradicting = item.get("contradicting_memories", [])
-
- title = obs.get("title", "")
- content = obs.get("content", "")
- existing_evidence = obs.get("evidence", [])
-
- parts.append(f"### Observation {i}: {title}")
- parts.append(f"Content: {content}")
- parts.append(f"Existing evidence count: {len(existing_evidence)}")
-
- if supporting:
- parts.append("\n**New Supporting Memories:**")
- for mem in supporting:
- mem_id = mem.get("id", "unknown")
- mem_content = mem.get("content", mem.get("text", ""))
- timestamp = mem.get("timestamp", mem.get("created_at", ""))
- parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
-
- if contradicting:
- parts.append("\n**New Contradicting Memories:**")
- for mem in contradicting:
- mem_id = mem.get("id", "unknown")
- mem_content = mem.get("content", mem.get("text", ""))
- timestamp = mem.get("timestamp", mem.get("created_at", ""))
- parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
-
- if not supporting and not contradicting:
- parts.append("\n*No new evidence found*")
-
- parts.append("")
-
- parts.append("## Instructions")
- parts.append("1. Extract EXACT quotes from new supporting memories")
- parts.append("2. Flag observations with strong contradictions")
- parts.append("3. Return the updated observations with new evidence added")
-
- return "\n".join(parts)
diff --git a/hindsight-api/hindsight_api/engine/reflect/tools.py b/hindsight-api/hindsight_api/engine/reflect/tools.py
index c7fba3ff..ef74ba24 100644
--- a/hindsight-api/hindsight_api/engine/reflect/tools.py
+++ b/hindsight-api/hindsight_api/engine/reflect/tools.py
@@ -1,16 +1,17 @@
"""
Tool implementations for the reflect agent.
+
+Implements hierarchical retrieval:
+1. search_reflections - User-curated summaries (highest quality)
+2. search_mental_models - Consolidated knowledge with freshness
+3. recall - Raw facts as ground truth
"""
import logging
-import re
import uuid
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
-from .models import MentalModelInput
-from .observations import Observation, ObservationEvidence, Trend
-
if TYPE_CHECKING:
from asyncpg import Connection
@@ -19,156 +20,216 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
-
-def generate_model_id(name: str) -> str:
- """Generate a stable ID from mental model name."""
- # Normalize: lowercase, replace spaces/special chars with hyphens
- normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
- # Truncate to reasonable length
- return normalized[:50]
+# Mental model is considered stale if not updated in this many days
+STALE_THRESHOLD_DAYS = 7
-def _parse_observations(observations_raw: list) -> list[Observation]:
- """Parse raw observation dicts into typed Observation models."""
- observations: list[Observation] = []
- for obs in observations_raw:
- if not isinstance(obs, dict):
- continue
-
- try:
- parsed = Observation(
- title=obs.get("title", ""),
- content=obs.get("content", ""),
- evidence=[
- ObservationEvidence(
- memory_id=ev.get("memory_id", ""),
- quote=ev.get("quote", ""),
- relevance=ev.get("relevance", ""),
- timestamp=ev.get("timestamp"),
- )
- for ev in obs.get("evidence", [])
- if isinstance(ev, dict)
- ],
- created_at=obs.get("created_at"),
- )
- observations.append(parsed)
- except Exception as e:
- logger.warning(f"Failed to parse observation: {e}")
- continue
-
- return observations
-
-
-async def tool_lookup(
+async def tool_search_reflections(
conn: "Connection",
bank_id: str,
- model_id: str | None = None,
+ query: str,
+ query_embedding: list[float],
+ max_results: int = 5,
tags: list[str] | None = None,
tags_match: str = "any",
+ exclude_ids: list[str] | None = None,
) -> dict[str, Any]:
"""
- List or get mental models.
+ Search user-curated reflections by semantic similarity.
+
+ Reflections are high-quality, manually created summaries about specific topics.
+ They should be searched FIRST as they represent the most reliable synthesized knowledge.
Args:
conn: Database connection
bank_id: Bank identifier
- model_id: Optional specific model ID to get (if None, lists all)
- tags: Optional tags to filter models (when listing)
+ query: Search query (for logging/tracing)
+ query_embedding: Pre-computed embedding for semantic search
+ max_results: Maximum number of reflections to return
+ tags: Optional tags to filter reflections
tags_match: How to match tags - "any" (OR), "all" (AND)
+ exclude_ids: Optional list of reflection IDs to exclude (e.g., when refreshing a reflection)
Returns:
- Dict with either a list of models or a single model's details
+ Dict with matching reflections including content and freshness info
"""
- if model_id:
- # Get specific mental model with full details including observations
- row = await conn.fetchrow(
- """
- SELECT id, subtype, name, description, observations, entity_id, last_updated
- FROM mental_models
- WHERE id = $1 AND bank_id = $2
- """,
- model_id,
- bank_id,
- )
- if row:
- # Parse observations JSON
- obs_data = row["observations"] or {"observations": []}
- if isinstance(obs_data, str):
- import json
+ from ..memory_engine import fq_table
- obs_data = json.loads(obs_data)
- observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data
+ # Build filters dynamically
+ filters = ""
+ params: list[Any] = [bank_id, str(query_embedding), max_results]
+ next_param = 4
- # Parse observations into typed models
- observations = _parse_observations(observations_raw)
-
- return {
- "found": True,
- "model": {
- "id": row["id"],
- "subtype": row["subtype"],
- "name": row["name"],
- "description": row["description"],
- "observations": observations,
- "entity_id": str(row["entity_id"]) if row["entity_id"] else None,
- "last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
- },
- }
- return {"found": False, "model_id": model_id}
- else:
- # List mental models (compact: id, name, description only)
- # Full observations are retrieved via get_mental_model(model_id)
- # NOTE: Directives (subtype='directive') are excluded from listing -
- # they are injected into the system prompt, not discoverable via tools
- # Filter by tags if provided
- if tags:
- if tags_match == "all":
- # All tags must match
- rows = await conn.fetch(
- """
- SELECT id, subtype, name, description
- FROM mental_models
- WHERE bank_id = $1 AND tags @> $2::varchar[] AND subtype != 'directive'
- ORDER BY last_updated DESC NULLS LAST, created_at DESC
- """,
- bank_id,
- tags,
- )
- else:
- # Any tag matches (OR) - default
- rows = await conn.fetch(
- """
- SELECT id, subtype, name, description
- FROM mental_models
- WHERE bank_id = $1 AND tags && $2::varchar[] AND subtype != 'directive'
- ORDER BY last_updated DESC NULLS LAST, created_at DESC
- """,
- bank_id,
- tags,
- )
+ if tags:
+ if tags_match == "all":
+ filters += f" AND tags @> ${next_param}::varchar[]"
else:
- rows = await conn.fetch(
- """
- SELECT id, subtype, name, description
- FROM mental_models
- WHERE bank_id = $1 AND subtype != 'directive'
- ORDER BY last_updated DESC NULLS LAST, created_at DESC
+ filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
+ params.append(tags)
+ next_param += 1
+
+ if exclude_ids:
+ filters += f" AND id != ALL(${next_param}::uuid[])"
+ params.append(exclude_ids)
+ next_param += 1
+
+ # Search reflections by embedding similarity
+ rows = await conn.fetch(
+ f"""
+ SELECT
+ id, name, content, reflect_response,
+ tags, created_at, last_refreshed_at,
+ 1 - (embedding <=> $2::vector) as relevance
+ FROM {fq_table("reflections")}
+ WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
+ ORDER BY embedding <=> $2::vector
+ LIMIT $3
+ """,
+ *params,
+ )
+
+ now = datetime.now(timezone.utc)
+ reflections = []
+
+ for row in rows:
+ last_refreshed_at = row["last_refreshed_at"]
+ if last_refreshed_at and last_refreshed_at.tzinfo is None:
+ last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc)
+
+ # Calculate freshness
+ is_stale = False
+ if last_refreshed_at:
+ age = now - last_refreshed_at
+ is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
+
+ reflections.append(
+ {
+ "id": str(row["id"]),
+ "name": row["name"],
+ "content": row["content"],
+ "reflect_response": row["reflect_response"],
+ "tags": row["tags"] or [],
+ "relevance": round(row["relevance"], 4),
+ "updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None,
+ "is_stale": is_stale,
+ }
+ )
+
+ return {
+ "query": query,
+ "count": len(reflections),
+ "reflections": reflections,
+ }
+
+
+async def tool_search_mental_models(
+ memory_engine: "MemoryEngine",
+ bank_id: str,
+ query: str,
+ request_context: "RequestContext",
+ max_tokens: int = 5000,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ last_consolidated_at: datetime | None = None,
+ pending_consolidation: int = 0,
+) -> dict[str, Any]:
+ """
+ Search consolidated mental models using recall with include_mental_models.
+
+ Mental models are auto-generated from memories. Returns freshness info
+ so the agent knows if it should also verify with recall().
+
+ Args:
+ memory_engine: Memory engine instance
+ bank_id: Bank identifier
+ query: Search query
+ request_context: Request context for authentication
+ max_tokens: Maximum tokens for results (default 5000)
+ tags: Optional tags to filter models
+ tags_match: How to match tags - "any" (OR), "all" (AND)
+ last_consolidated_at: When consolidation last ran (for staleness check)
+ pending_consolidation: Number of memories waiting to be consolidated
+
+ Returns:
+ Dict with matching mental models including freshness info
+ """
+ from ..memory_engine import fq_table
+
+ # Use recall to search mental models (they come back in results field when fact_type=["mental_model"])
+ result = await memory_engine.recall_async(
+ bank_id=bank_id,
+ query=query,
+ fact_type=["mental_model"], # Only retrieve mental models
+ max_tokens=max_tokens, # Token budget controls how many mental models are returned
+ enable_trace=False,
+ request_context=request_context,
+ tags=tags,
+ tags_match=tags_match,
+ _connection_budget=1,
+ _quiet=True,
+ )
+
+ mental_models = []
+
+ # When fact_type=["mental_model"], results come back in `results` field as MemoryFact objects
+ # We need to fetch additional fields (proof_count, source_memory_ids) from the database
+ if result.results:
+ mm_ids = [m.id for m in result.results]
+
+ # Fetch proof_count and source_memory_ids for these mental models
+ pool = await memory_engine._get_pool()
+ async with pool.acquire() as conn:
+ mm_rows = await conn.fetch(
+ f"""
+ SELECT id, proof_count, source_memory_ids
+ FROM {fq_table("memory_units")}
+ WHERE id = ANY($1::uuid[])
""",
- bank_id,
+ mm_ids,
+ )
+ mm_data = {str(row["id"]): row for row in mm_rows}
+
+ for m in result.results:
+ # Get additional data from DB lookup
+ extra = mm_data.get(m.id, {})
+ proof_count = extra.get("proof_count", 1) if extra else 1
+ source_ids = extra.get("source_memory_ids", []) if extra else []
+ # Convert UUIDs to strings
+ source_memory_ids = [str(sid) for sid in (source_ids or [])]
+
+ # Determine staleness
+ is_stale = False
+ staleness_reason = None
+ if pending_consolidation > 0:
+ is_stale = True
+ staleness_reason = f"{pending_consolidation} memories pending consolidation"
+
+ mental_models.append(
+ {
+ "id": str(m.id),
+ "text": m.text,
+ "proof_count": proof_count,
+ "source_memory_ids": source_memory_ids,
+ "tags": m.tags or [],
+ "is_stale": is_stale,
+ "staleness_reason": staleness_reason,
+ }
)
- return {
- "count": len(rows),
- "models": [
- {
- "id": row["id"],
- "subtype": row["subtype"],
- "name": row["name"],
- "description": row["description"],
- }
- for row in rows
- ],
- }
+ # Return freshness info (more understandable than raw pending_consolidation count)
+ if pending_consolidation == 0:
+ freshness = "up_to_date"
+ elif pending_consolidation < 10:
+ freshness = "slightly_stale"
+ else:
+ freshness = "stale"
+
+ return {
+ "query": query,
+ "count": len(mental_models),
+ "mental_models": mental_models,
+ "freshness": freshness,
+ }
async def tool_recall(
@@ -185,6 +246,9 @@ async def tool_recall(
"""
Search memories using TEMPR retrieval.
+ This is the ground truth - raw facts and experiences.
+ Use when reflections/mental models don't exist, are stale, or need verification.
+
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
@@ -202,13 +266,14 @@ async def tool_recall(
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
- fact_type=["experience", "world"], # Exclude opinions
+ fact_type=["experience", "world"], # Exclude opinions and mental_models
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
tags=tags,
tags_match=tags_match,
_connection_budget=connection_budget,
+ _quiet=True, # Suppress logging for internal operations
)
memories = []
@@ -230,85 +295,6 @@ async def tool_recall(
}
-async def tool_learn(
- conn: "Connection",
- bank_id: str,
- input: MentalModelInput,
- tags: list[str] | None = None,
-) -> dict[str, Any]:
- """
- Create a mental model placeholder with subtype='learned'.
-
- The agent only specifies name and description - actual observations are generated
- in the background via refresh, similar to pinned models.
-
- Args:
- conn: Database connection
- bank_id: Bank identifier
- input: Mental model input data (name, description, optional entity_id)
- tags: Tags to apply to new mental models (from reflect context)
-
- Returns:
- Dict with created model info including model_id for background generation
- """
- model_id = generate_model_id(input.name)
-
- # Parse entity_id if provided
- entity_uuid = None
- if input.entity_id:
- try:
- entity_uuid = uuid.UUID(input.entity_id)
- except ValueError:
- logger.warning(f"Invalid entity_id format: {input.entity_id}")
-
- # Check if model exists
- existing = await conn.fetchrow(
- "SELECT id FROM mental_models WHERE id = $1 AND bank_id = $2",
- model_id,
- bank_id,
- )
-
- if existing:
- # Update description only - observations will be regenerated
- await conn.execute(
- """
- UPDATE mental_models SET
- description = $3,
- entity_id = $4
- WHERE id = $1 AND bank_id = $2
- """,
- model_id,
- bank_id,
- input.description,
- entity_uuid,
- )
- status = "updated"
- else:
- # Insert new model placeholder - observations will be generated in background
- await conn.execute(
- """
- INSERT INTO mental_models (id, bank_id, subtype, name, description, observations, entity_id, tags, created_at)
- VALUES ($1, $2, 'learned', $3, $4, '{}'::jsonb, $5, $6, NOW())
- """,
- model_id,
- bank_id,
- input.name,
- input.description,
- entity_uuid,
- tags or [],
- )
- status = "created"
-
- logger.info(f"[REFLECT] Mental model '{model_id}' {status} in bank {bank_id} - pending background generation")
-
- return {
- "status": status,
- "model_id": model_id,
- "name": input.name,
- "pending_generation": True,
- }
-
-
async def tool_expand(
conn: "Connection",
bank_id: str,
@@ -327,6 +313,8 @@ async def tool_expand(
Returns:
Dict with results array, each containing memory, chunk, and optionally document data
"""
+ from ..memory_engine import fq_table
+
if not memory_ids:
return {"error": "memory_ids is required and must not be empty"}
@@ -344,9 +332,9 @@ async def tool_expand(
# Batch fetch all memory units
memories = await conn.fetch(
- """
+ f"""
SELECT id, text, chunk_id, document_id, fact_type, context
- FROM memory_units
+ FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2
""",
valid_uuids,
@@ -363,9 +351,9 @@ async def tool_expand(
chunk_map: dict[str, Any] = {}
if chunk_ids:
chunks = await conn.fetch(
- """
+ f"""
SELECT chunk_id, chunk_text, chunk_index, document_id
- FROM chunks
+ FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1)
""",
chunk_ids,
@@ -385,9 +373,9 @@ async def tool_expand(
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
if all_doc_ids:
docs = await conn.fetch(
- """
+ f"""
SELECT id, original_text, metadata, retain_params
- FROM documents
+ FROM {fq_table("documents")}
WHERE id = ANY($1) AND bank_id = $2
""",
all_doc_ids,
diff --git a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py
index 060e3bdd..4b85e31e 100644
--- a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py
+++ b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py
@@ -2,36 +2,62 @@
Tool schema definitions for the reflect agent.
These are OpenAI-format tool definitions used with native tool calling.
+The reflect agent uses a hierarchical retrieval strategy:
+1. search_reflections - User-curated summaries (highest quality, if applicable)
+2. search_mental_models - Consolidated knowledge with freshness awareness
+3. recall - Raw facts (world/experience) as ground truth fallback
"""
# Tool definitions in OpenAI format
-TOOL_LIST_MENTAL_MODELS = {
+
+TOOL_SEARCH_REFLECTIONS = {
"type": "function",
"function": {
- "name": "list_mental_models",
- "description": "List all available mental models - your synthesized knowledge about entities, concepts, and events. Returns an array of models with id, name, and description.",
+ "name": "search_reflections",
+ "description": (
+ "Search user-curated reflections (summaries). These are high-quality, manually created "
+ "summaries about specific topics. Use FIRST when the question might be covered by an "
+ "existing reflection. Returns reflections with their content and last refresh time."
+ ),
"parameters": {
"type": "object",
- "properties": {},
- "required": [],
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search query to find relevant reflections",
+ },
+ "max_results": {
+ "type": "integer",
+ "description": "Maximum number of reflections to return (default 5)",
+ },
+ },
+ "required": ["query"],
},
},
}
-TOOL_GET_MENTAL_MODEL = {
+TOOL_SEARCH_MENTAL_MODELS = {
"type": "function",
"function": {
- "name": "get_mental_model",
- "description": "Get full details of a specific mental model including all observations and memory references.",
+ "name": "search_mental_models",
+ "description": (
+ "Search consolidated mental models (auto-generated knowledge). These are automatically "
+ "synthesized from memories. Returns models with freshness info (updated_at, is_stale). "
+ "If a model is STALE, you should ALSO use recall() to verify with current facts."
+ ),
"parameters": {
"type": "object",
"properties": {
- "model_id": {
+ "query": {
"type": "string",
- "description": "ID of the mental model (from list_mental_models results)",
+ "description": "Search query to find relevant mental models",
+ },
+ "max_tokens": {
+ "type": "integer",
+ "description": "Maximum tokens for results (default 5000). Use higher values for broader searches.",
},
},
- "required": ["model_id"],
+ "required": ["query"],
},
},
}
@@ -40,7 +66,12 @@ TOOL_RECALL = {
"type": "function",
"function": {
"name": "recall",
- "description": "Search memories using semantic + temporal retrieval. Returns relevant memories from experience and world knowledge, each with an 'id' you can reference.",
+ "description": (
+ "Search raw memories (facts and experiences). This is the ground truth data. "
+ "Use when: (1) no reflections/mental models exist, (2) mental models are stale, "
+ "(3) you need specific details not in synthesized knowledge. "
+ "Returns individual memory facts with their timestamps."
+ ),
"parameters": {
"type": "object",
"properties": {
@@ -58,28 +89,6 @@ TOOL_RECALL = {
},
}
-TOOL_LEARN = {
- "type": "function",
- "function": {
- "name": "learn",
- "description": "Create a new mental model to track an important recurring topic. Use when you discover a person, project, concept, or pattern that appears frequently and would benefit from synthesized knowledge. The model content will be generated automatically.",
- "parameters": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "description": "Human-readable name (e.g., 'Project Alpha', 'John Smith', 'Product Strategy')",
- },
- "description": {
- "type": "string",
- "description": "What to track and synthesize (e.g., 'Track goals, milestones, blockers, and key decisions for Project Alpha')",
- },
- },
- "required": ["name", "description"],
- },
- },
-}
-
TOOL_EXPAND = {
"type": "function",
"function": {
@@ -121,7 +130,12 @@ TOOL_DONE_ANSWER = {
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
- "model_ids": {
+ "reflection_ids": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Array of reflection IDs that support your answer",
+ },
+ "mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
@@ -143,8 +157,6 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
Args:
directive_rules: List of directive rule strings
"""
- from typing import Any, cast
-
# Build rules list for description
rules_list = "\n".join(f" {i + 1}. {rule}" for i, rule in enumerate(directive_rules))
@@ -169,7 +181,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
- "model_ids": {
+ "reflection_ids": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Array of reflection IDs that support your answer",
+ },
+ "mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
@@ -185,29 +202,28 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
}
-def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]:
+def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
"""
Get the list of tools for the reflect agent.
+ The tools support a hierarchical retrieval strategy:
+ 1. search_reflections - User-curated summaries (try first)
+ 2. search_mental_models - Consolidated knowledge with freshness
+ 3. recall - Raw facts as ground truth
+
Args:
- enable_learn: Whether to include the learn tool
directive_rules: Optional list of directive rule strings. If provided,
the done() tool will require directive compliance confirmation.
Returns:
List of tool definitions in OpenAI format
"""
- tools = []
-
- # Include mental model tools for lookup
- tools.append(TOOL_LIST_MENTAL_MODELS)
- tools.append(TOOL_GET_MENTAL_MODEL)
- tools.append(TOOL_RECALL)
-
- if enable_learn:
- tools.append(TOOL_LEARN)
-
- tools.append(TOOL_EXPAND)
+ tools = [
+ TOOL_SEARCH_REFLECTIONS,
+ TOOL_SEARCH_MENTAL_MODELS,
+ TOOL_RECALL,
+ TOOL_EXPAND,
+ ]
# Use directive-aware done tool if directives are present
if directive_rules:
diff --git a/hindsight-api/hindsight_api/engine/response_models.py b/hindsight-api/hindsight_api/engine/response_models.py
index 42fa4cff..12c827ee 100644
--- a/hindsight-api/hindsight_api/engine/response_models.py
+++ b/hindsight-api/hindsight_api/engine/response_models.py
@@ -11,7 +11,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
-VALID_RECALL_FACT_TYPES = frozenset(["world", "experience"])
+VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "mental_model"])
class LLMToolCall(BaseModel):
@@ -166,6 +166,28 @@ class ChunkInfo(BaseModel):
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
+class MentalModelResult(BaseModel):
+ """A mental model result from recall."""
+
+ id: str = Field(description="Unique mental model ID")
+ text: str = Field(description="The mental model text")
+ proof_count: int = Field(description="Number of facts supporting this mental model")
+ relevance: float = Field(default=0.0, description="Relevance score to the query")
+ tags: list[str] | None = Field(default=None, description="Tags for visibility scoping")
+ source_memory_ids: list[str] = Field(
+ default_factory=list, description="IDs of facts that contribute to this mental model"
+ )
+
+
+class ReflectionResult(BaseModel):
+ """A reflection result from recall."""
+
+ id: str = Field(description="Unique reflection ID")
+ name: str = Field(description="Human-readable name")
+ content: str = Field(description="The synthesized content")
+ relevance: float = Field(default=0.0, description="Relevance score to the query")
+
+
class RecallResult(BaseModel):
"""
Result from a recall operation.
@@ -229,6 +251,7 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
+ "mental-models": [],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
@@ -239,7 +262,7 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: dict[str, list[MemoryFact]] = Field(
- description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
+ description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental-models)"
)
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
structured_output: dict[str, Any] | None = Field(
@@ -258,10 +281,6 @@ class ReflectResult(BaseModel):
default_factory=list,
description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.",
)
- mental_models: list[MentalModelRef] = Field(
- default_factory=list,
- description="Mental models accessed during reflection, including directives (subtype='directive').",
- )
directives_applied: list[DirectiveRef] = Field(
default_factory=list,
description="Directive mental models that were applied during this reflection.",
diff --git a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py
index 205f9791..b798ae67 100644
--- a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py
+++ b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py
@@ -114,11 +114,8 @@ class CausalRelation(BaseModel):
"""Causal relationship from this fact to a previous fact (stored format)."""
target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).")
- relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
- description="How this fact relates to the target: "
- "'caused_by' = this fact was caused by the target, "
- "'enabled_by' = this fact was enabled by the target, "
- "'prevented_by' = this fact was prevented by the target"
+ relation_type: Literal["caused_by"] = Field(
+ description="How this fact relates to the target: 'caused_by' = this fact was caused by the target"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0)",
@@ -141,11 +138,8 @@ class FactCausalRelation(BaseModel):
"MUST be less than this fact's position in the list. "
"Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
)
- relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
- description="How this fact relates to the target fact: "
- "'caused_by' = this fact was caused by the target fact, "
- "'enabled_by' = this fact was enabled by the target fact, "
- "'prevented_by' = this fact was blocked/prevented by the target fact"
+ relation_type: Literal["caused_by"] = Field(
+ description="How this fact relates to the target fact: 'caused_by' = this fact was caused by the target fact"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
@@ -662,7 +656,7 @@ CAUSAL RELATIONSHIPS
══════════════════════════════════════════════════════════════════════════
Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
-Types: "caused_by", "enabled_by", "prevented_by"
+Type: "caused_by" (this fact was caused by the target fact)
Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 0: Lost job, causal_relations: null
@@ -823,7 +817,8 @@ Text:
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
- fact_type = llm_fact.get("fact_type")
+ original_fact_type = llm_fact.get("fact_type")
+ fact_type = original_fact_type
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
@@ -840,7 +835,10 @@ Text:
else:
# Default to 'world' if we can't determine
fact_type = "world"
- logger.warning(f"Fact {i}: defaulting to fact_type='world'")
+ logger.warning(
+ f"Fact {i}: defaulting to fact_type='world' "
+ f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
+ )
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
diff --git a/hindsight-api/hindsight_api/engine/retain/link_utils.py b/hindsight-api/hindsight_api/engine/retain/link_utils.py
index 19c5ccc9..79dce42e 100644
--- a/hindsight-api/hindsight_api/engine/retain/link_utils.py
+++ b/hindsight-api/hindsight_api/engine/retain/link_utils.py
@@ -754,17 +754,14 @@ async def create_causal_links_batch(
causal_relations_per_fact: List of causal relations for each fact.
Each element is a list of dicts with:
- target_fact_index: Index into unit_ids for the target fact
- - relation_type: "causes", "caused_by", "enables", or "prevents"
+ - relation_type: "caused_by"
- strength: Float in [0.0, 1.0] representing relationship strength
Returns:
Number of causal links created
- Causal link types:
- - "causes": This fact directly causes the target fact (forward causation)
- - "caused_by": This fact was caused by the target fact (backward causation)
- - "enables": This fact enables/allows the target fact (enablement)
- - "prevents": This fact prevents/blocks the target fact (prevention)
+ Causal link type:
+ - "caused_by": This fact was caused by the target fact
"""
if not unit_ids or not causal_relations_per_fact:
return 0
@@ -787,8 +784,8 @@ async def create_causal_links_batch(
relation_type = relation["relation_type"]
strength = relation.get("strength", 1.0)
- # Validate relation_type - must match database constraint
- valid_types = {"causes", "caused_by", "enables", "prevents"}
+ # Validate relation_type - only "caused_by" is supported (DB constraint)
+ valid_types = {"caused_by"}
if relation_type not in valid_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
diff --git a/hindsight-api/hindsight_api/engine/retain/types.py b/hindsight-api/hindsight_api/engine/retain/types.py
index 528a2d02..19157dee 100644
--- a/hindsight-api/hindsight_api/engine/retain/types.py
+++ b/hindsight-api/hindsight_api/engine/retain/types.py
@@ -86,10 +86,10 @@ class CausalRelation:
"""
Causal relationship between facts.
- Represents how one fact causes, enables, or prevents another.
+ Represents how one fact was caused by another.
"""
- relation_type: str # "causes", "enables", "prevents", "caused_by"
+ relation_type: str # "caused_by"
target_fact_index: int # Index of the target fact in the batch
strength: float = 1.0 # Strength of the causal relationship
diff --git a/hindsight-api/hindsight_api/engine/search/tracer.py b/hindsight-api/hindsight_api/engine/search/tracer.py
index c2d436b1..19247998 100644
--- a/hindsight-api/hindsight_api/engine/search/tracer.py
+++ b/hindsight-api/hindsight_api/engine/search/tracer.py
@@ -330,8 +330,8 @@ class SearchTracer:
RetrievalResult(
rank=rank,
node_id=doc_id,
- text=data.get("text", ""),
- context=data.get("context", ""),
+ text=data.get("text") or "",
+ context=data.get("context") or "",
event_date=data.get("event_date"),
fact_type=data.get("fact_type") or fact_type,
score=score,
diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py
index 87a271e3..fd84a272 100644
--- a/hindsight-api/hindsight_api/extensions/__init__.py
+++ b/hindsight-api/hindsight_api/extensions/__init__.py
@@ -27,8 +27,6 @@ from hindsight_api.extensions.operation_validator import (
RecallResult,
ReflectContext,
ReflectResultContext,
- RefreshMentalModelContext,
- RefreshMentalModelResult,
RetainContext,
RetainResult,
ValidationResult,
@@ -56,8 +54,6 @@ __all__ = [
"RecallResult",
"ReflectContext",
"ReflectResultContext",
- "RefreshMentalModelContext",
- "RefreshMentalModelResult",
"RetainContext",
"RetainResult",
"ValidationResult",
diff --git a/hindsight-api/hindsight_api/extensions/operation_validator.py b/hindsight-api/hindsight_api/extensions/operation_validator.py
index 8dd88eaa..a1dec0eb 100644
--- a/hindsight-api/hindsight_api/extensions/operation_validator.py
+++ b/hindsight-api/hindsight_api/extensions/operation_validator.py
@@ -97,18 +97,6 @@ class ReflectContext:
context: str | None = None
-@dataclass
-class RefreshMentalModelContext:
- """Context for a refresh mental model operation validation (pre-operation).
-
- Contains ALL user-provided parameters for the refresh mental model operation.
- """
-
- bank_id: str
- model_id: str
- request_context: "RequestContext"
-
-
# =============================================================================
# Post-operation Contexts (includes results)
# =============================================================================
@@ -176,27 +164,6 @@ class ReflectResultContext:
error: str | None = None
-@dataclass
-class RefreshMentalModelResult:
- """Result context for post-refresh-mental-model hook.
-
- Contains the operation parameters and the result including token usage.
- """
-
- bank_id: str
- model_id: str
- request_context: "RequestContext"
- # Result
- model_name: str | None = None
- observations_count: int = 0
- input_tokens: int = 0
- output_tokens: int = 0
- total_tokens: int = 0
- duration_ms: int = 0
- success: bool = True
- error: str | None = None
-
-
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect operations.
@@ -298,25 +265,6 @@ class OperationValidatorExtension(Extension, ABC):
"""
...
- @abstractmethod
- async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
- """
- Validate a refresh mental model operation before execution.
-
- Called before the refresh mental model operation is processed.
- Return ValidationResult.reject() to prevent the operation from executing.
-
- Args:
- ctx: Context containing all user-provided parameters:
- - bank_id: Bank identifier
- - model_id: Mental model ID to refresh
- - request_context: Request context with auth info
-
- Returns:
- ValidationResult indicating whether the operation is allowed.
- """
- ...
-
# =========================================================================
# Post-operation hooks (optional - override to implement)
# =========================================================================
@@ -377,28 +325,3 @@ class OperationValidatorExtension(Extension, ABC):
- error: Error message (if failed)
"""
pass
-
- async def on_refresh_mental_model_complete(self, result: RefreshMentalModelResult) -> None:
- """
- Called after a refresh mental model operation completes (success or failure).
-
- Override this method to implement post-operation logic such as:
- - Token usage tracking and billing
- - Audit logging
- - Metrics collection
-
- Args:
- result: Result context containing:
- - bank_id: Bank identifier
- - model_id: Mental model ID
- - request_context: Request context with auth info
- - model_name: Name of the mental model (if success)
- - observations_count: Number of observations generated
- - input_tokens: Number of input tokens used
- - output_tokens: Number of output tokens used
- - total_tokens: Total tokens used (input + output)
- - duration_ms: Total operation duration in milliseconds
- - success: Whether the operation succeeded
- - error: Error message (if failed)
- """
- pass
diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py
index 44a2872b..728dba9e 100644
--- a/hindsight-api/hindsight_api/main.py
+++ b/hindsight-api/hindsight_api/main.py
@@ -212,6 +212,9 @@ def main():
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_observations_async=config.retain_observations_async,
+ enable_mental_models=config.enable_mental_models,
+ consolidation_similarity_threshold=config.consolidation_similarity_threshold,
+ consolidation_batch_size=config.consolidation_batch_size,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
run_migrations_on_startup=config.run_migrations_on_startup,
diff --git a/hindsight-api/hindsight_api/worker/poller.py b/hindsight-api/hindsight_api/worker/poller.py
index e85f8a18..52cbecbc 100644
--- a/hindsight-api/hindsight_api/worker/poller.py
+++ b/hindsight-api/hindsight_api/worker/poller.py
@@ -8,6 +8,7 @@ FOR UPDATE SKIP LOCKED for safe concurrent claiming.
import asyncio
import json
import logging
+import time
import traceback
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
@@ -17,6 +18,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+# Progress logging interval in seconds
+PROGRESS_LOG_INTERVAL = 30
+
def fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
@@ -66,6 +70,9 @@ class WorkerPoller:
self._current_tasks: set[asyncio.Task] = set()
self._in_flight_count = 0
self._in_flight_lock = asyncio.Lock()
+ self._last_progress_log = 0.0
+ self._tasks_completed_since_log = 0
+ self._active_banks: set[str] = set()
async def claim_batch(self) -> list[tuple[str, dict[str, Any]]]:
"""
@@ -73,6 +80,9 @@ class WorkerPoller:
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
+ For consolidation tasks specifically, skips pending tasks if there's already
+ a processing consolidation for the same bank (to avoid duplicate work).
+
Returns:
List of tuples (operation_id, task_dict)
"""
@@ -81,11 +91,24 @@ class WorkerPoller:
async with self._pool.acquire() as conn:
async with conn.transaction():
# Select and lock pending tasks
+ # For consolidation: skip if same bank already has one processing
rows = await conn.fetch(
f"""
SELECT operation_id, task_payload
- FROM {table}
+ FROM {table} AS pending
WHERE status = 'pending' AND task_payload IS NOT NULL
+ AND (
+ -- Non-consolidation tasks: always claimable
+ operation_type != 'consolidation'
+ OR
+ -- Consolidation: only if no other consolidation processing for same bank
+ NOT EXISTS (
+ SELECT 1 FROM {table} AS processing
+ WHERE processing.bank_id = pending.bank_id
+ AND processing.operation_type = 'consolidation'
+ AND processing.status = 'processing'
+ )
+ )
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
@@ -188,6 +211,34 @@ class WorkerPoller:
logger.error(f"Task {operation_id} failed: {e}")
await self._retry_or_fail(operation_id, error_msg)
+ async def recover_own_tasks(self) -> int:
+ """
+ Recover tasks that were assigned to this worker but not completed.
+
+ This handles the case where a worker crashes while processing tasks.
+ On startup, we reset any tasks stuck in 'processing' for this worker_id
+ back to 'pending' so they can be picked up again.
+
+ Returns:
+ Number of tasks recovered
+ """
+ table = fq_table("async_operations", self._schema)
+
+ result = await self._pool.execute(
+ f"""
+ UPDATE {table}
+ SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
+ WHERE status = 'processing' AND worker_id = $1
+ """,
+ self._worker_id,
+ )
+
+ # Parse "UPDATE N" to get count
+ count = int(result.split()[-1]) if result else 0
+ if count > 0:
+ logger.info(f"Worker {self._worker_id} recovered {count} stale tasks from previous run")
+ return count
+
async def run(self):
"""
Main polling loop.
@@ -195,6 +246,9 @@ class WorkerPoller:
Continuously polls for pending tasks, claims them, and executes them
until shutdown is signaled.
"""
+ # Recover any tasks from a previous crash before starting
+ await self.recover_own_tasks()
+
logger.info(f"Worker {self._worker_id} starting polling loop")
while not self._shutdown.is_set():
@@ -234,6 +288,9 @@ class WorkerPoller:
except asyncio.TimeoutError:
pass # Normal timeout, continue polling
+ # Log progress stats periodically
+ await self._log_progress_if_due()
+
except asyncio.CancelledError:
logger.info(f"Worker {self._worker_id} polling loop cancelled")
break
@@ -270,6 +327,72 @@ class WorkerPoller:
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s")
+ async def _log_progress_if_due(self):
+ """Log progress stats every PROGRESS_LOG_INTERVAL seconds."""
+ now = time.time()
+ if now - self._last_progress_log < PROGRESS_LOG_INTERVAL:
+ return
+
+ self._last_progress_log = now
+
+ try:
+ table = fq_table("async_operations", self._schema)
+ async with self._pool.acquire() as conn:
+ # Get global stats by status
+ stats = await conn.fetch(
+ f"""
+ SELECT status, COUNT(*) as count
+ FROM {table}
+ WHERE created_at > now() - interval '24 hours'
+ GROUP BY status
+ """
+ )
+
+ # Get currently processing tasks grouped by type and bank
+ processing = await conn.fetch(
+ f"""
+ SELECT operation_type, bank_id, COUNT(*) as count
+ FROM {table}
+ WHERE status = 'processing'
+ GROUP BY operation_type, bank_id
+ """
+ )
+
+ # Build stats dict
+ status_counts = {row["status"]: row["count"] for row in stats}
+ pending = status_counts.get("pending", 0)
+ processing_count = status_counts.get("processing", 0)
+ completed = status_counts.get("completed", 0)
+ failed = status_counts.get("failed", 0)
+
+ # Build processing breakdown
+ processing_info = []
+ banks_working = set()
+ for row in processing:
+ op_type = row["operation_type"]
+ bank_id = row["bank_id"]
+ count = row["count"]
+ banks_working.add(bank_id)
+ processing_info.append(f"{op_type}:{bank_id}({count})")
+
+ # Format log
+ async with self._in_flight_lock:
+ in_flight = self._in_flight_count
+
+ processing_str = ", ".join(processing_info[:10]) if processing_info else "none"
+ if len(processing_info) > 10:
+ processing_str += f" +{len(processing_info) - 10} more"
+
+ logger.info(
+ f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | "
+ f"global: pending={pending} processing={processing_count} "
+ f"completed_24h={completed} failed_24h={failed} | "
+ f"active: {processing_str}"
+ )
+
+ except Exception as e:
+ logger.debug(f"Failed to log progress stats: {e}")
+
@property
def worker_id(self) -> str:
"""Get the worker ID."""
diff --git a/hindsight-api/tests/test_consolidation.py b/hindsight-api/tests/test_consolidation.py
new file mode 100644
index 00000000..0535b0d4
--- /dev/null
+++ b/hindsight-api/tests/test_consolidation.py
@@ -0,0 +1,1587 @@
+"""Integration tests for the consolidation engine.
+
+These tests exercise the real consolidation implementation with actual database operations.
+Note: Consolidation runs automatically after retain via SyncTaskBackend in tests.
+"""
+
+import uuid
+from unittest.mock import patch
+
+import pytest
+
+from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
+from hindsight_api.engine.memory_engine import MemoryEngine
+from hindsight_api.engine.reflect.tools import (
+ tool_recall,
+ tool_search_mental_models,
+ tool_search_reflections,
+)
+
+
+@pytest.fixture(autouse=True)
+def enable_mental_models():
+ """Enable mental models for all tests in this module."""
+ from hindsight_api.config import get_config
+
+ config = get_config()
+ original_value = config.enable_mental_models
+ config.enable_mental_models = True
+ yield
+ config.enable_mental_models = original_value
+
+
+class TestConsolidationIntegration:
+ """Integration tests for consolidation with real database.
+
+ These tests verify that consolidation creates mental models correctly.
+ Since we use SyncTaskBackend in tests, consolidation runs synchronously
+ after retain completes.
+ """
+
+ @pytest.mark.asyncio
+ async def test_consolidation_creates_mental_model_after_retain(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation creates a mental model after retain."""
+ bank_id = f"test-consolidation-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically after
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Peter loves hiking in the mountains every weekend.",
+ request_context=request_context,
+ )
+
+ # Verify mental model exists in memory_units
+ # (consolidation already ran as part of retain via SyncTaskBackend)
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, proof_count, fact_type
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ # Mental model may or may not be created depending on LLM relevance judgment
+ # The important thing is no errors occurred
+ if mental_models:
+ mm = mental_models[0]
+ assert mm["proof_count"] >= 1
+ assert mm["fact_type"] == "mental_model"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_processes_multiple_memories(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation processes multiple related memories."""
+ bank_id = f"test-consolidation-multi-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain first memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Peter enjoys hiking on mountain trails.",
+ request_context=request_context,
+ )
+
+ # Retain a second related memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Peter went hiking in the Alps last weekend and loved it.",
+ request_context=request_context,
+ )
+
+ # Check mental models after both retains
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, proof_count
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY proof_count DESC
+ """,
+ bank_id,
+ )
+
+ # Should have at least one mental model
+ # If the LLM determined both memories support the same model,
+ # proof_count might be > 1
+ if mental_models:
+ # Verify structure is correct
+ assert all(mm["text"] for mm in mental_models)
+ assert all(mm["proof_count"] >= 1 for mm in mental_models)
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_no_new_memories(self, memory: MemoryEngine, request_context):
+ """Test that consolidation handles case when no new memories exist."""
+ bank_id = f"test-consolidation-empty-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Run consolidation without any memories
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ assert result["status"] == "no_new_memories"
+ assert result["memories_processed"] == 0
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_respects_last_consolidated_at(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation only processes memories created after last_consolidated_at."""
+ bank_id = f"test-consolidation-timestamp-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Alice works at a technology company.",
+ request_context=request_context,
+ )
+
+ # Run consolidation again - should have no new memories
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ # Should report no new memories since consolidation already ran
+ assert result["status"] == "no_new_memories"
+ assert result["memories_processed"] == 0
+
+ # Add a new memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Alice got promoted to senior engineer.",
+ request_context=request_context,
+ )
+
+ # Run consolidation again - should also have no new memories
+ # because consolidation ran automatically after the second retain
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ assert result["status"] == "no_new_memories"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_copies_entity_links(self, memory: MemoryEngine, request_context):
+ """Test that mental models inherit entity links from source memories."""
+ bank_id = f"test-consolidation-entities-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory with a named entity
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="John Smith is the CEO of Acme Corporation.",
+ request_context=request_context,
+ )
+
+ # Check mental model and its entity links
+ async with memory._pool.acquire() as conn:
+ mental_model = await conn.fetchrow(
+ """
+ SELECT id
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ LIMIT 1
+ """,
+ bank_id,
+ )
+
+ if mental_model:
+ # Check if entity links were copied
+ entity_links = await conn.fetch(
+ """
+ SELECT entity_id
+ FROM unit_entities
+ WHERE unit_id = $1
+ """,
+ mental_model["id"],
+ )
+ # Mental model should have inherited entity links from source memory
+ # (may be empty if no entities were extracted, which is fine)
+ assert entity_links is not None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_mental_models_included_in_recall(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models created by consolidation are returned in recall."""
+ bank_id = f"test-consolidation-recall-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah is an expert Python programmer who specializes in machine learning.",
+ request_context=request_context,
+ )
+
+ # Recall with mental models included
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does Sarah do?",
+ fact_type=["world", "experience", "mental_model"],
+ request_context=request_context,
+ )
+
+ # Mental models come back as regular results with fact_type='mental_model'
+ assert hasattr(recall_result, "results")
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_creates_memory_links(self, memory: MemoryEngine, request_context):
+ """Test that mental models get bidirectional links to their source memories."""
+ bank_id = f"test-consolidation-links-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Maria works as a software engineer at Microsoft.",
+ request_context=request_context,
+ )
+
+ # Check memory_links between mental model and source memory
+ async with memory._pool.acquire() as conn:
+ mental_model = await conn.fetchrow(
+ """
+ SELECT id, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ LIMIT 1
+ """,
+ bank_id,
+ )
+
+ if mental_model and mental_model["source_memory_ids"]:
+ source_memory_id = mental_model["source_memory_ids"][0]
+
+ # Check that bidirectional links exist
+ link_from_memory = await conn.fetchrow(
+ """
+ SELECT * FROM memory_links
+ WHERE from_unit_id = $1 AND to_unit_id = $2
+ """,
+ source_memory_id,
+ mental_model["id"],
+ )
+ link_to_memory = await conn.fetchrow(
+ """
+ SELECT * FROM memory_links
+ WHERE from_unit_id = $1 AND to_unit_id = $2
+ """,
+ mental_model["id"],
+ source_memory_id,
+ )
+
+ # Both directions should have links
+ assert link_from_memory is not None, "Expected link from source memory to mental model"
+ assert link_to_memory is not None, "Expected link from mental model to source memory"
+ assert link_from_memory["link_type"] == "semantic"
+ assert link_to_memory["link_type"] == "semantic"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_merges_only_redundant_facts(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation only merges truly redundant facts.
+
+ Mental models should be fine-grained (almost 1:1 with memories).
+ Only merge when facts are truly redundant (saying the same thing differently)
+ or when one directly updates another (e.g., location change).
+
+ Given:
+ - "Nicolò lives in Italy"
+ - "Nicolò moved to the US recently" (updates the living location)
+
+ The second fact should UPDATE the first, not create a separate model.
+ But unrelated facts like "Nicolò works at Vectorize" should stay separate.
+ """
+ bank_id = f"test-consolidation-merge-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory about living location
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò lives in Italy.",
+ request_context=request_context,
+ )
+
+ # Retain an unrelated memory (different topic - should NOT merge)
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò works at Vectorize as an engineer.",
+ request_context=request_context,
+ )
+
+ # Check mental models - should have 2 separate models
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Add a memory that UPDATES the living location (should merge with first)
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò recently moved to the United States.",
+ request_context=request_context,
+ )
+
+ # Check mental models after consolidation
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, proof_count, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Key assertions:
+ # 1. Consolidation ran without errors
+ # 2. Mental models exist
+ assert len(mental_models) >= 1, "Expected at least one mental model"
+
+ # The work-related fact should remain separate from location facts
+ # (LLM behavior varies, so we check structure rather than exact count)
+ for mm in mental_models:
+ assert mm["text"], "Mental model should have text"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_keeps_different_people_separate(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation NEVER merges facts about different people.
+
+ Each person's facts should stay in separate mental models.
+ """
+ bank_id = f"test-consolidation-people-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Add facts about different people
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="John lives in New York.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Mary lives in Boston.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Bob works at Google.",
+ request_context=request_context,
+ )
+
+ # Check mental models - should have separate models for each person
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Should have multiple mental models (one per person/fact)
+ # Not everything merged into one
+ assert len(mental_models) >= 2, (
+ f"Expected multiple mental models for different people, got {len(mental_models)}"
+ )
+
+ # No single mental model should mention multiple different people
+ # (This is a structural check - each model should be focused)
+ for mm in mental_models:
+ text = mm["text"].lower()
+ people_mentioned = sum([
+ 1 for name in ["john", "mary", "bob"]
+ if name in text
+ ])
+ assert people_mentioned <= 1, (
+ f"Mental model should not merge different people: {mm['text']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_merges_contradictions(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that contradictions about the same topic are merged with history.
+
+ When facts contradict each other (same person, same topic, opposite info),
+ they should be merged into ONE mental model that captures the change.
+
+ Example:
+ - "Nicolò loves pizza"
+ - "Nicolò hates pizza"
+ → Should become: "Nicolò used to love pizza but now hates it" (or similar)
+ """
+ bank_id = f"test-consolidation-contradict-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Add initial fact
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò loves pizza.",
+ request_context=request_context,
+ )
+
+ # Check we have one mental model
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+
+ # Add contradicting fact (same person, same topic, opposite sentiment)
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò hates pizza.",
+ request_context=request_context,
+ )
+
+ # Check mental models after consolidation
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, source_memory_ids, history
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Key assertion: Should NOT have more mental models than before
+ # The contradiction should be merged, not create a new model
+ assert len(mental_models) <= count_before, (
+ f"Contradiction should merge, not create new model. "
+ f"Before: {count_before}, After: {len(mental_models)}. "
+ f"Models: {[mm['text'] for mm in mental_models]}"
+ )
+
+ # The merged model should capture both sentiments or the change
+ if mental_models:
+ merged_text = mental_models[0]["text"].lower()
+ # Should mention the change or both states
+ has_history = (
+ ("used to" in merged_text or "now" in merged_text or "but" in merged_text)
+ or ("love" in merged_text and "hate" in merged_text)
+ or (len(mental_models[0]["source_memory_ids"] or []) > 1)
+ )
+ assert has_history, (
+ f"Merged model should capture the change. Got: {mental_models[0]['text']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestConsolidationDisabled:
+ """Test consolidation when disabled via config."""
+
+ @pytest.mark.asyncio
+ async def test_consolidation_returns_disabled_status(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation returns disabled status when enable_mental_models is False."""
+ from unittest.mock import patch
+
+ bank_id = f"test-consolidation-disabled-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Disable mental models via config
+ with patch("hindsight_api.config.get_config") as mock_config:
+ mock_config.return_value.enable_mental_models = False
+
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ assert result["status"] == "disabled"
+ assert result["bank_id"] == bank_id
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestRecallMentalModelFactType:
+ """Test recall with mental_model as a fact type."""
+
+ @pytest.mark.asyncio
+ async def test_recall_with_mental_model_fact_type(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental_model can be used as a fact type in recall.
+
+ When mental_model is in the types list, the recall should:
+ 1. Return mental models in the results field with fact_type='mental_model'
+ 2. Not raise validation errors for None context fields
+ """
+ bank_id = f"test-recall-mm-type-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Alex is a data scientist who specializes in deep learning and neural networks.",
+ request_context=request_context,
+ )
+
+ # Recall with mental_model in types
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does Alex do?",
+ fact_type=["mental_model"],
+ request_context=request_context,
+ )
+
+ # Mental models come back as regular results with fact_type='mental_model'
+ assert recall_result is not None
+ assert recall_result.results is not None
+ # Check that results include mental models
+ if recall_result.results:
+ for mm in recall_result.results:
+ assert mm.id is not None
+ assert mm.text is not None
+ assert mm.fact_type == "mental_model"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_recall_with_mixed_fact_types_including_mental_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test recall with mental_model alongside world and experience types."""
+ bank_id = f"test-recall-mixed-types-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain memories - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Jordan is a professional musician who plays guitar in a rock band.",
+ request_context=request_context,
+ )
+
+ # Recall with all types including mental_model
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does Jordan do?",
+ fact_type=["world", "experience", "mental_model"],
+ enable_trace=True,
+ request_context=request_context,
+ )
+
+ # Should return results without errors
+ assert recall_result is not None
+ # Should have results from world/experience facts
+ assert recall_result.results is not None
+ # Mental models come back as regular results with fact_type='mental_model'
+ # when mental_model is included in fact_type parameter
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_recall_mental_model_only_with_trace(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that recall with only mental_model type and trace enabled works.
+
+ This specifically tests the tracer handling of mental models with None context.
+ """
+ bank_id = f"test-recall-mm-trace-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain memory - consolidation creates mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Chris works as a product manager at a startup focused on AI applications.",
+ request_context=request_context,
+ )
+
+ # Recall with mental_model only and trace enabled
+ # This tests the fix for the None context validation error
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="Where does Chris work?",
+ fact_type=["mental_model"],
+ enable_trace=True,
+ request_context=request_context,
+ )
+
+ # Should complete without validation errors
+ assert recall_result is not None
+ # Trace should be populated
+ assert recall_result.trace is not None or recall_result.mental_models is not None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestConsolidationTagRouting:
+ """Test tag routing during consolidation.
+
+ Tag routing rules:
+ - Same scope (tags match): update existing mental model
+ - Fact scoped, model global (untagged): update global (it absorbs all)
+ - Different scopes (non-overlapping tags): create untagged cross-scope insight
+ - No match: create with fact's tags
+ """
+
+ async def _retain_with_tags(
+ self,
+ memory: MemoryEngine,
+ bank_id: str,
+ content: str,
+ tags: list[str],
+ request_context,
+ ):
+ """Helper to retain content with tags using retain_batch_async."""
+ await memory.retain_batch_async(
+ bank_id=bank_id,
+ contents=[{"content": content}],
+ document_tags=tags,
+ request_context=request_context,
+ )
+
+ @pytest.mark.asyncio
+ async def test_same_scope_updates_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that a tagged fact updates a mental model with the same tags.
+
+ Given:
+ - Memory with tags=['alice']: "Alice likes coffee"
+ - New memory with tags=['alice']: "Alice prefers espresso"
+
+ Expected:
+ - Mental model with tags=['alice'] is updated to reflect both facts
+ """
+ bank_id = f"test-tag-same-scope-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain first memory with tags
+ await self._retain_with_tags(
+ memory, bank_id, "Alice likes coffee.", ["alice"], request_context
+ )
+
+ # Check mental model has correct tags
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+ if mm_before:
+ assert "alice" in (mm_before[0]["tags"] or []), (
+ f"Expected mental model to have 'alice' tag, got: {mm_before[0]['tags']}"
+ )
+
+ # Retain related memory with same tags
+ await self._retain_with_tags(
+ memory, bank_id, "Alice prefers espresso over regular coffee.", ["alice"], request_context
+ )
+
+ # Check mental models - should NOT have increased (same scope update)
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Count of mental models should stay same or decrease (merge)
+ assert len(mm_after) <= count_before + 1, (
+ f"Same scope fact should update existing model, not create new. "
+ f"Before: {count_before}, After: {len(mm_after)}"
+ )
+
+ # The model(s) should still have alice tag
+ for mm in mm_after:
+ if "coffee" in mm["text"].lower() or "espresso" in mm["text"].lower():
+ assert "alice" in (mm["tags"] or []), (
+ f"Updated model should keep 'alice' tag: {mm['text']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_scoped_fact_updates_global_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that a scoped fact can update an untagged (global) mental model.
+
+ Given:
+ - Untagged memory: "Pizza is a popular food"
+ - New memory with tags=['history']: "Pizza originated in Naples"
+
+ Expected:
+ - The global mental model is updated (global absorbs all scopes)
+ """
+ bank_id = f"test-tag-global-absorb-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain untagged (global) memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Pizza is a popular Italian food.",
+ request_context=request_context,
+ )
+
+ # Check untagged mental model exists
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+ # Should be untagged or have empty tags
+ if mm_before:
+ assert not mm_before[0]["tags"] or len(mm_before[0]["tags"]) == 0, (
+ f"Expected untagged model, got: {mm_before[0]['tags']}"
+ )
+
+ # Retain scoped memory that relates to the global topic
+ await self._retain_with_tags(
+ memory, bank_id, "Pizza originated in Naples.", ["history"], request_context
+ )
+
+ # Check - global model should be updated OR new scoped model created
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # At least one model should exist
+ assert len(mm_after) >= 1, "Expected at least one mental model"
+
+ # Check that global model was updated (source_memory_ids increased)
+ # OR new model was created with appropriate tags
+ global_models = [m for m in mm_after if not m["tags"] or len(m["tags"]) == 0]
+ scoped_models = [m for m in mm_after if m["tags"] and len(m["tags"]) > 0]
+
+ # Either global was updated or scoped was created
+ assert len(global_models) >= 1 or len(scoped_models) >= 1, (
+ "Expected either global model update or scoped model creation"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_cross_scope_creates_untagged(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that cross-scope related facts create untagged (global) insights.
+
+ Given:
+ - Memory with tags=['alice']: "Alice recommends the Thai restaurant"
+ - Memory with tags=['bob']: "Bob tried the Thai restaurant Alice mentioned"
+
+ Expected:
+ - A new untagged mental model capturing the cross-scope insight
+ """
+ bank_id = f"test-tag-cross-scope-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain Alice's scoped memory
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice recommends the Thai restaurant on Main Street.",
+ ["alice"], request_context
+ )
+
+ # Check Alice's mental model exists with correct tags
+ async with memory._pool.acquire() as conn:
+ mm_alice = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_alice)
+
+ # Retain Bob's memory that relates to Alice's topic (cross-scope)
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Bob visited the Thai restaurant on Main Street and loved it.",
+ ["bob"], request_context
+ )
+
+ # Check mental models
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Should have multiple models (alice's, bob's, potentially global)
+ assert len(mm_after) >= 2, (
+ f"Expected at least 2 mental models for different scopes, got {len(mm_after)}"
+ )
+
+ # Check we have models with different tags (alice, bob, or untagged)
+ tag_sets = [frozenset(m["tags"] or []) for m in mm_after]
+
+ # Should NOT merge alice and bob into same model
+ models_with_both = [
+ m for m in mm_after
+ if m["tags"] and "alice" in m["tags"] and "bob" in m["tags"]
+ ]
+ assert len(models_with_both) == 0, (
+ "Should not merge different scopes into one model with both tags"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_no_match_creates_with_fact_tags(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that a new fact with no matching models creates a model with fact's tags.
+
+ Given:
+ - Empty bank
+ - Memory with tags=['project_x']: "Project X uses Python"
+
+ Expected:
+ - Mental model created with tags=['project_x']
+ """
+ bank_id = f"test-tag-new-scoped-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain tagged memory (no existing mental models)
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Project X uses Python for its backend services.",
+ ["project_x"], request_context
+ )
+
+ # Check mental model was created with correct tags
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ assert len(mental_models) >= 1, "Expected mental model to be created"
+
+ # The model should have the fact's tags
+ mm = mental_models[0]
+ assert mm["tags"] is not None, "Mental model should have tags"
+ assert "project_x" in mm["tags"], (
+ f"Mental model should have 'project_x' tag, got: {mm['tags']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_untagged_fact_can_update_scoped_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that an untagged fact can update a scoped mental model.
+
+ Given:
+ - Memory with tags=['alice']: "Alice works on machine learning"
+ - Untagged memory: "Machine learning involves neural networks"
+
+ Expected:
+ - The scoped model may be updated with the global insight
+ - OR a global model is created
+ """
+ bank_id = f"test-tag-untagged-update-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain scoped memory
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice works on machine learning projects.",
+ ["alice"], request_context
+ )
+
+ # Retain untagged memory on same topic
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Machine learning involves training neural networks.",
+ request_context=request_context,
+ )
+
+ # Check mental models
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Should have at least one model
+ assert len(mental_models) >= 1, "Expected at least one mental model"
+
+ # Either alice's model was updated OR a global model was created
+ # This is valid LLM behavior - just verify no errors and structure is correct
+ for mm in mental_models:
+ assert mm["text"], "Mental model should have text"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_tag_filtering_in_recall(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models respect tag filtering during recall.
+
+ Mental models should be filtered by tags just like memories.
+ """
+ bank_id = f"test-tag-recall-filter-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain memories with different tags
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice works as a software engineer.",
+ ["alice"], request_context
+ )
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Bob works as a product manager.",
+ ["bob"], request_context
+ )
+
+ # Recall with alice tag only
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does everyone do for work?",
+ tags=["alice"],
+ tags_match="any_strict", # Only alice's data
+ fact_type=["world", "experience", "mental_model"],
+ request_context=request_context,
+ )
+
+ # Results should only include alice-tagged content
+ # Mental models are now regular results with fact_type='mental_model'
+ mental_models = [r for r in recall_result.results if r.fact_type == "mental_model"]
+ for mm in mental_models:
+ # Mental model should be alice-scoped or global (untagged)
+ # Not bob-scoped
+ mm_tags = mm.tags or []
+ assert "bob" not in mm_tags, (
+ f"Recall with tags=['alice'] should not return bob's models: {mm.text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_multiple_actions_from_single_fact(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that one fact can trigger multiple consolidation actions.
+
+ Given:
+ - Global model: "Coffee is a popular beverage"
+ - Alice's model: "Alice drinks coffee every morning"
+ - New fact with tags=['alice']: "Alice switched to decaf coffee"
+
+ Expected:
+ - Update Alice's scoped model (same scope)
+ - Potentially update global model too (global absorbs all)
+ """
+ bank_id = f"test-tag-multi-action-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create global model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Coffee is a popular beverage worldwide.",
+ request_context=request_context,
+ )
+
+ # Create alice's scoped model
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice drinks coffee every morning.",
+ ["alice"], request_context
+ )
+
+ # Check models before
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+
+ # Add fact that could relate to both
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice switched to decaf coffee for health reasons.",
+ ["alice"], request_context
+ )
+
+ # Check models after
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids, proof_count FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Should have processed without errors
+ assert len(mm_after) >= 1, "Expected at least one mental model"
+
+ # Check that consolidation worked (either updates or maintains structure)
+ # The key is no errors and proper tag handling
+ for mm in mm_after:
+ assert mm["text"], "Mental model should have text"
+ # Tags should be consistent (not mixing alice and bob, etc.)
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_inherits_dates_from_source_memory(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models inherit occurred_start and event_date from source memories.
+
+ When a mental model is created, it should inherit the temporal information
+ from the source memory that triggered its creation, not use the current time.
+ """
+ from datetime import datetime, timezone
+
+ bank_id = f"test-consolidation-dates-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create a specific date in the past for testing
+ past_date = datetime(2023, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
+
+ # First, create a memory unit directly with a specific date
+ async with memory._pool.acquire() as conn:
+ memory_id = uuid.uuid4()
+ await conn.execute(
+ """
+ INSERT INTO memory_units (
+ id, bank_id, text, fact_type, occurred_start, event_date, created_at
+ )
+ VALUES ($1, $2, $3, 'experience', $4, $4, now())
+ """,
+ memory_id,
+ bank_id,
+ "Sarah went to Paris for vacation and loved the Eiffel Tower.",
+ past_date,
+ )
+
+ # Run consolidation manually
+ from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
+
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ # Verify consolidation processed the memory
+ assert result["status"] == "completed"
+ assert result["memories_processed"] >= 1
+
+ # Check that mental model inherited the date from source memory
+ async with memory._pool.acquire() as conn:
+ mental_model = await conn.fetchrow(
+ """
+ SELECT id, text, occurred_start, event_date, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ LIMIT 1
+ """,
+ bank_id,
+ )
+
+ if mental_model:
+ # Mental model should have inherited the date from the source memory
+ mm_occurred = mental_model["occurred_start"]
+ mm_event_date = mental_model["event_date"]
+
+ # Dates should match the source memory's date (2023-06-15), not today
+ assert mm_occurred is not None, "Mental model should have occurred_start"
+ assert mm_event_date is not None, "Mental model should have event_date"
+
+ # The date should be from 2023, not today
+ assert mm_occurred.year == 2023, (
+ f"Expected occurred_start year 2023, got {mm_occurred.year}. "
+ "Mental model should inherit date from source memory."
+ )
+ assert mm_occurred.month == 6, f"Expected month 6, got {mm_occurred.month}"
+ assert mm_occurred.day == 15, f"Expected day 15, got {mm_occurred.day}"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestMentalModelDrillDown:
+ """Test that reflect agent can drill down from mental models to source memories."""
+
+ @pytest.mark.asyncio
+ async def test_search_mental_models_returns_source_memory_ids(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that search_mental_models returns source_memory_ids for drill-down.
+
+ This verifies the agent can:
+ 1. Find a mental model
+ 2. Access its source_memory_ids
+ 3. Use those IDs to expand/recall for more details
+ """
+ from hindsight_api.engine.reflect.tools import tool_search_mental_models, tool_expand
+
+ bank_id = f"test-mm-drilldown-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Store memories with specific details that get summarized in mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah works at TechCorp as a senior software engineer since March 2020.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah's employee ID at TechCorp is EMP-12345.",
+ request_context=request_context,
+ )
+
+ # Search for mental models
+ result = await tool_search_mental_models(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="Sarah TechCorp",
+ request_context=request_context,
+ )
+
+ assert result["count"] > 0, "Expected at least one mental model"
+
+ # Verify source_memory_ids and proof_count are present
+ mm = result["mental_models"][0]
+ assert "source_memory_ids" in mm, "Mental model should have source_memory_ids"
+ assert "proof_count" in mm, "Mental model should have proof_count"
+ assert mm["proof_count"] >= 1, "proof_count should be at least 1"
+
+ # If source_memory_ids exist, verify they can be used with expand
+ if mm["source_memory_ids"]:
+ assert len(mm["source_memory_ids"]) >= 1, "Should have at least one source memory"
+
+ # Use expand tool to get source memory details
+ async with memory._pool.acquire() as conn:
+ expand_result = await tool_expand(
+ conn=conn,
+ bank_id=bank_id,
+ memory_ids=mm["source_memory_ids"][:2], # Take first 2
+ depth="chunk",
+ )
+
+ assert "results" in expand_result
+ assert len(expand_result["results"]) > 0, "Expand should return source memories"
+
+ # Verify we get the original detailed information
+ all_text = " ".join(r["memory"]["text"] for r in expand_result["results"] if "memory" in r)
+ # The expanded memories should contain details not necessarily in the mental model
+ assert "Sarah" in all_text or "TechCorp" in all_text, (
+ f"Expanded memories should contain source details. Got: {all_text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_mental_model_source_ids_match_contributing_memories(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that source_memory_ids actually point to the memories that built the mental model."""
+ bank_id = f"test-mm-source-ids-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Store two related memories
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Project Phoenix was started by the engineering team in January 2024.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Project Phoenix achieved 99.9% uptime in its first quarter.",
+ request_context=request_context,
+ )
+
+ # Get the mental model with source_memory_ids
+ async with memory._pool.acquire() as conn:
+ mm_rows = await conn.fetch(
+ """
+ SELECT id, text, proof_count, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ if mm_rows:
+ mm = mm_rows[0]
+ source_ids = mm["source_memory_ids"] or []
+
+ # Verify source_memory_ids point to actual memories
+ if source_ids:
+ async with memory._pool.acquire() as conn:
+ source_memories = await conn.fetch(
+ """
+ SELECT id, text FROM memory_units
+ WHERE id = ANY($1) AND fact_type IN ('world', 'experience')
+ """,
+ source_ids,
+ )
+
+ # Should have found the source memories
+ assert len(source_memories) >= 1, (
+ f"source_memory_ids should point to valid memories. "
+ f"IDs: {source_ids}, Found: {len(source_memories)}"
+ )
+
+ # The source memories should contain our original content
+ source_texts = [m["text"].lower() for m in source_memories]
+ has_phoenix = any("phoenix" in t for t in source_texts)
+ assert has_phoenix, f"Source memories should contain original content. Got: {source_texts}"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestHierarchicalRetrieval:
+ """Test the reflect agent's hierarchical retrieval tools.
+
+ The hierarchy is:
+ 1. search_reflections - User-curated summaries (highest quality)
+ 2. search_mental_models - Auto-consolidated knowledge
+ 3. recall - Raw facts as ground truth
+
+ When a reflection matches the query, it should be used first.
+ """
+
+ @pytest.mark.asyncio
+ async def test_reflection_takes_priority_over_mental_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that reflections are found and would be used before mental models.
+
+ Given:
+ - A memory about "John's favorite color is blue"
+ - A mental model created from that memory (via consolidation)
+ - A reflection manually created about John
+
+ When searching, the reflection should be found first.
+ """
+ bank_id = f"test-hierarchy-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation creates a mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="John's favorite color is blue and he likes painting.",
+ request_context=request_context,
+ )
+
+ # Verify mental model was created
+ async with memory._pool.acquire() as conn:
+ mm_count = await conn.fetchval(
+ """
+ SELECT COUNT(*) FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ assert mm_count >= 1, "Consolidation should have created a mental model"
+
+ # Create a reflection about John (higher quality, user-curated)
+ reflection = await memory.create_reflection(
+ bank_id=bank_id,
+ name="John's Preferences",
+ source_query="What are John's preferences?",
+ content="John is an artist who loves the color blue. He has been painting for 10 years and prefers watercolors.",
+ tags=[],
+ request_context=request_context,
+ )
+ assert reflection["id"] is not None
+
+ # Search reflections - should find our reflection
+ async with memory._pool.acquire() as conn:
+ query_embedding = memory.embeddings.encode(["What does John like?"])[0]
+ reflection_result = await tool_search_reflections(
+ conn=conn,
+ bank_id=bank_id,
+ query="What does John like?",
+ query_embedding=query_embedding,
+ max_results=5,
+ )
+
+ # Reflection should be found
+ assert reflection_result["count"] >= 1, "Reflection should be found"
+ found_reflection = reflection_result["reflections"][0]
+ assert "John" in found_reflection["content"] or "blue" in found_reflection["content"]
+
+ # Search mental models - should also find something
+ mm_result = await tool_search_mental_models(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="What does John like?",
+ request_context=request_context,
+ max_tokens=5000,
+ )
+ assert mm_result["count"] >= 1, "Mental model should also be found"
+
+ # Verify the reflection has higher quality content (more detail)
+ reflection_content = found_reflection["content"]
+ mm_content = mm_result["mental_models"][0]["text"]
+
+ # The reflection should contain the richer, user-curated content
+ assert "watercolors" in reflection_content or "10 years" in reflection_content, (
+ f"Reflection should have the rich user-curated content. Got: {reflection_content}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_fallback_to_mental_model_when_no_reflection(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models are used when no reflection matches.
+
+ Given:
+ - A memory about "Sarah works at Google"
+ - A mental model created from that memory
+ - NO reflection about Sarah
+
+ When searching, mental models should provide the information.
+ """
+ bank_id = f"test-hierarchy-fallback-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation creates a mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah works at Google as a software engineer.",
+ request_context=request_context,
+ )
+
+ # Search reflections - should find nothing
+ async with memory._pool.acquire() as conn:
+ query_embedding = memory.embeddings.encode(["Where does Sarah work?"])[0]
+ reflection_result = await tool_search_reflections(
+ conn=conn,
+ bank_id=bank_id,
+ query="Where does Sarah work?",
+ query_embedding=query_embedding,
+ max_results=5,
+ )
+
+ # No reflections exist
+ assert reflection_result["count"] == 0, "No reflections should exist"
+
+ # Search mental models - should find the consolidated knowledge
+ mm_result = await tool_search_mental_models(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="Where does Sarah work?",
+ request_context=request_context,
+ max_tokens=5000,
+ )
+
+ # Mental model should be found
+ assert mm_result["count"] >= 1, "Mental model should be found when no reflection exists"
+ mm_text = mm_result["mental_models"][0]["text"].lower()
+ assert "sarah" in mm_text or "google" in mm_text, (
+ f"Mental model should contain info about Sarah. Got: {mm_text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_fallback_to_recall_for_fresh_data(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that recall provides raw facts when needed for verification.
+
+ This tests the drill-down capability: when mental models are stale or
+ need verification, recall provides the original source facts.
+ """
+ bank_id = f"test-hierarchy-recall-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain some specific memories
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="The quarterly revenue was $1.5M in Q3 2024.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="The quarterly revenue was $2.1M in Q4 2024.",
+ request_context=request_context,
+ )
+
+ # Use recall to get the raw facts
+ recall_result = await tool_recall(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="What was the quarterly revenue?",
+ request_context=request_context,
+ max_tokens=2048,
+ max_results=10,
+ )
+
+ # Should have raw facts with specific numbers
+ assert recall_result["count"] >= 1, "Recall should find the raw facts"
+
+ # Check that we get the actual numbers from the original memories
+ all_memory_text = " ".join([m["text"] for m in recall_result["memories"]])
+ assert "$1.5M" in all_memory_text or "$2.1M" in all_memory_text, (
+ f"Recall should return raw facts with specific data. Got: {all_memory_text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
diff --git a/hindsight-api/tests/test_emergent_filtering.py b/hindsight-api/tests/test_emergent_filtering.py
deleted file mode 100644
index a26d7723..00000000
--- a/hindsight-api/tests/test_emergent_filtering.py
+++ /dev/null
@@ -1,516 +0,0 @@
-"""Tests for emergent entity filtering."""
-
-import pytest
-from unittest.mock import AsyncMock, MagicMock
-
-from hindsight_api.engine.mental_models.emergent import (
- build_mission_filter_prompt,
- evaluate_emergent_models,
- filter_candidates_by_mission,
- MissionFilterResponse,
- MissionFilterCandidate,
-)
-from hindsight_api.engine.mental_models.models import EmergentCandidate
-
-
-class TestBuildMissionFilterPrompt:
- """Test prompt building for mission filtering."""
-
- def test_prompt_contains_mission(self):
- """Test that prompt includes the mission."""
- candidates = [
- EmergentCandidate(
- name="Alice",
- detection_method="named_entity_extraction",
- mention_count=10,
- )
- ]
- prompt = build_mission_filter_prompt("Be a PM for engineering team", candidates)
- assert "Be a PM for engineering team" in prompt
-
- def test_prompt_contains_candidates(self):
- """Test that prompt includes all candidates."""
- candidates = [
- EmergentCandidate(
- name="Alice Chen",
- detection_method="named_entity_extraction",
- mention_count=10,
- ),
- EmergentCandidate(
- name="Project Phoenix",
- detection_method="named_entity_extraction",
- mention_count=5,
- ),
- ]
- prompt = build_mission_filter_prompt("Track projects", candidates)
- assert "Alice Chen" in prompt
- assert "Project Phoenix" in prompt
-
- def test_prompt_contains_rejection_guidance(self):
- """Test that prompt contains guidance to reject generic entities."""
- candidates = [
- EmergentCandidate(
- name="test",
- detection_method="named_entity_extraction",
- mention_count=1,
- )
- ]
- prompt = build_mission_filter_prompt("Test mission", candidates)
-
- # Should contain rejection guidance for generic terms
- assert "promote=false" in prompt
- assert "kids" in prompt # Example of generic term to reject
- assert "community" in prompt # Example of abstract concept to reject
- assert "motivation" in prompt # Example of abstract concept to reject
-
-
-class TestFilterCandidatesByMission:
- """Test the filter_candidates_by_mission function."""
-
- @pytest.fixture
- def mock_llm_config(self):
- """Create a mock LLM config."""
- config = MagicMock()
- config.call = AsyncMock()
- return config
-
- async def test_empty_candidates(self, mock_llm_config):
- """Test with empty candidate list."""
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="Test mission",
- candidates=[],
- )
- assert result == []
- mock_llm_config.call.assert_not_called()
-
- async def test_no_mission_keeps_all(self, mock_llm_config):
- """Test that no mission keeps all candidates (skips filtering)."""
- candidates = [
- EmergentCandidate(
- name="Alice",
- detection_method="named_entity_extraction",
- mention_count=10,
- )
- ]
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="", # Empty mission
- candidates=candidates,
- )
- assert len(result) == 1
- assert result[0].name == "Alice"
- mock_llm_config.call.assert_not_called()
-
- async def test_filters_by_promote_flag(self, mock_llm_config):
- """Test that candidates are filtered by promote flag."""
- candidates = [
- EmergentCandidate(
- name="Alice Chen",
- detection_method="named_entity_extraction",
- mention_count=10,
- ),
- EmergentCandidate(
- name="community",
- detection_method="named_entity_extraction",
- mention_count=5,
- ),
- ]
-
- # Mock LLM response - Alice is promoted, community is not
- mock_llm_config.call.return_value = MissionFilterResponse(
- candidates=[
- MissionFilterCandidate(name="Alice Chen", promote=True, reason="Specific person"),
- MissionFilterCandidate(name="community", promote=False, reason="Generic abstract concept"),
- ]
- )
-
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="Be a PM for engineering team",
- candidates=candidates,
- )
-
- assert len(result) == 1
- assert result[0].name == "Alice Chen"
-
- async def test_rejects_generic_entities(self, mock_llm_config):
- """Test that generic entities are rejected."""
- # These are all generic/abstract terms that should be rejected
- generic_names = [
- "user", "support", "community", "family", "motivation",
- "photo", "gratitude", "difference", "volunteering",
- "kids", "veterans", "impact", "kindness", "encouragement",
- "education", "nature", "joy", "positivity", "inspiration",
- "help", "commitment", "passion", "energy", "connection",
- ]
- candidates = [
- EmergentCandidate(
- name=name,
- detection_method="named_entity_extraction",
- mention_count=10,
- )
- for name in generic_names
- ]
-
- # Add some valid candidates
- valid_candidates = [
- EmergentCandidate(
- name="John",
- detection_method="named_entity_extraction",
- mention_count=10,
- ),
- EmergentCandidate(
- name="Maria",
- detection_method="named_entity_extraction",
- mention_count=8,
- ),
- EmergentCandidate(
- name="Max",
- detection_method="named_entity_extraction",
- mention_count=6,
- ),
- ]
- candidates.extend(valid_candidates)
-
- # Mock LLM response - reject all generic, promote only specific names
- response_candidates = [
- MissionFilterCandidate(name=name, promote=False, reason="Generic/abstract term")
- for name in generic_names
- ]
- response_candidates.extend([
- MissionFilterCandidate(name=c.name, promote=True, reason="Specific person name")
- for c in valid_candidates
- ])
-
- mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
-
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="Be a health coach",
- candidates=candidates,
- )
-
- # Should only have John, Maria, and Max
- result_names = {c.name for c in result}
- assert result_names == {"John", "Maria", "Max"}
-
- async def test_accepts_specific_named_entities(self, mock_llm_config):
- """Test that specific named entities are accepted."""
- # These should all be accepted
- valid_names = [
- "Alice Chen", # Full name
- "Dr. Smith", # Title + name
- "John", # First name (when it's clearly a person)
- "Google", # Organization
- "Frontend Team", # Named team
- "Project Phoenix", # Named project
- "NYC Office", # Named place
- "Q4 Planning", # Named event
- "Sprint 23 Review", # Named meeting
- ]
- candidates = [
- EmergentCandidate(
- name=name,
- detection_method="named_entity_extraction",
- mention_count=10,
- )
- for name in valid_names
- ]
-
- # Mock LLM response - promote all
- response_candidates = [
- MissionFilterCandidate(name=name, promote=True, reason="Specific named entity")
- for name in valid_names
- ]
- mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
-
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="Be a PM for engineering team",
- candidates=candidates,
- )
-
- # Should have all valid names
- result_names = {c.name for c in result}
- assert result_names == set(valid_names)
-
- async def test_llm_error_rejects_all_candidates(self, mock_llm_config):
- """Test that LLM errors result in rejecting all candidates (fail-safe)."""
- candidates = [
- EmergentCandidate(
- name="Alice",
- detection_method="named_entity_extraction",
- mention_count=10,
- )
- ]
-
- mock_llm_config.call.side_effect = Exception("LLM error")
-
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="Test mission",
- candidates=candidates,
- )
-
- # Should reject all candidates on error (fail-safe)
- assert len(result) == 0
-
- async def test_missing_candidate_in_response_is_rejected(self, mock_llm_config):
- """Test that candidates not in LLM response are rejected by default."""
- candidates = [
- EmergentCandidate(
- name="Alice",
- detection_method="named_entity_extraction",
- mention_count=10,
- ),
- EmergentCandidate(
- name="Bob",
- detection_method="named_entity_extraction",
- mention_count=5,
- ),
- ]
-
- # Mock LLM response - only includes Alice, not Bob
- mock_llm_config.call.return_value = MissionFilterResponse(
- candidates=[
- MissionFilterCandidate(name="Alice", promote=True, reason="Specific person"),
- ]
- )
-
- result = await filter_candidates_by_mission(
- llm_config=mock_llm_config,
- mission="Test mission",
- candidates=candidates,
- )
-
- # Only Alice should be in result (Bob was missing from response, so rejected)
- assert len(result) == 1
- assert result[0].name == "Alice"
-
-
-class TestEvaluateEmergentModels:
- """Test the evaluate_emergent_models function for cleanup of existing models."""
-
- @pytest.fixture
- def mock_llm_config(self):
- """Create a mock LLM config."""
- config = MagicMock()
- config.call = AsyncMock()
- return config
-
- async def test_empty_models(self, mock_llm_config):
- """Test with empty model list."""
- result = await evaluate_emergent_models(
- llm_config=mock_llm_config,
- models=[],
- )
- assert result == []
- mock_llm_config.call.assert_not_called()
-
- async def test_removes_generic_models(self, mock_llm_config):
- """Test that generic/abstract models are marked for removal."""
- models = [
- {"id": "id-kids", "name": "kids"},
- {"id": "id-community", "name": "community"},
- {"id": "id-motivation", "name": "motivation"},
- {"id": "id-john", "name": "John"},
- {"id": "id-maria", "name": "Maria"},
- ]
-
- # Mock LLM response - reject generic, keep specific names
- mock_llm_config.call.return_value = MissionFilterResponse(
- candidates=[
- MissionFilterCandidate(name="kids", promote=False, reason="Generic category"),
- MissionFilterCandidate(name="community", promote=False, reason="Abstract concept"),
- MissionFilterCandidate(name="motivation", promote=False, reason="Abstract concept"),
- MissionFilterCandidate(name="John", promote=True, reason="Person name"),
- MissionFilterCandidate(name="Maria", promote=True, reason="Person name"),
- ]
- )
-
- result = await evaluate_emergent_models(
- llm_config=mock_llm_config,
- models=models,
- )
-
- # Should return IDs of generic models to remove
- assert set(result) == {"id-kids", "id-community", "id-motivation"}
-
- async def test_keeps_specific_named_models(self, mock_llm_config):
- """Test that specific named models are kept."""
- models = [
- {"id": "id-john", "name": "John"},
- {"id": "id-google", "name": "Google"},
- {"id": "id-project", "name": "Project Phoenix"},
- ]
-
- # Mock LLM response - keep all
- mock_llm_config.call.return_value = MissionFilterResponse(
- candidates=[
- MissionFilterCandidate(name="John", promote=True, reason="Person name"),
- MissionFilterCandidate(name="Google", promote=True, reason="Organization"),
- MissionFilterCandidate(name="Project Phoenix", promote=True, reason="Named project"),
- ]
- )
-
- result = await evaluate_emergent_models(
- llm_config=mock_llm_config,
- models=models,
- )
-
- # No models should be removed
- assert result == []
-
- async def test_llm_error_keeps_all_models(self, mock_llm_config):
- """Test that LLM errors result in keeping all models (safe default)."""
- models = [
- {"id": "id-kids", "name": "kids"},
- {"id": "id-john", "name": "John"},
- ]
-
- mock_llm_config.call.side_effect = Exception("LLM error")
-
- result = await evaluate_emergent_models(
- llm_config=mock_llm_config,
- models=models,
- )
-
- # Should keep all models on error (return empty removal list)
- assert result == []
-
- async def test_missing_model_in_response_is_removed(self, mock_llm_config):
- """Test that models not in LLM response are marked for removal."""
- models = [
- {"id": "id-alice", "name": "Alice"},
- {"id": "id-bob", "name": "Bob"},
- ]
-
- # Mock LLM response - only includes Alice
- mock_llm_config.call.return_value = MissionFilterResponse(
- candidates=[
- MissionFilterCandidate(name="Alice", promote=True, reason="Person name"),
- ]
- )
-
- result = await evaluate_emergent_models(
- llm_config=mock_llm_config,
- models=models,
- )
-
- # Bob should be marked for removal (missing from response)
- assert result == ["id-bob"]
-
-
-class TestRemovedEntitiesNotRepromoted:
- """Test that entities removed by evaluation are not re-promoted.
-
- This tests the fix for a bug where:
- 1. evaluate_emergent_models returns model IDs to remove (e.g., 'entity-maya')
- 2. We delete those models
- 3. detect_entity_candidates finds the same entities (now eligible since model was deleted)
- 4. filter_candidates_by_goal approves them (different LLM call)
- 5. BUG: We were re-promoting the same entities we just removed
-
- The fix tracks removed entity_ids and excludes them from promotion.
- """
-
- async def test_removed_entity_ids_excluded_from_promotion(self):
- """Test that entities whose models were removed are not re-promoted."""
- from hindsight_api.engine.mental_models.models import EmergentCandidate
-
- # Simulate the scenario from the bug:
- # - existing_emergent has model 'entity-maya' with entity_id='uuid-maya'
- # - evaluate_emergent_models says to remove 'entity-maya'
- # - detect_entity_candidates returns 'Maya' with entity_id='uuid-maya' (now eligible)
- # - filter_candidates_by_goal says to promote 'Maya'
- # - But we should NOT promote because we just removed it
-
- existing_emergent = [
- {"id": "entity-maya", "name": "Maya", "entity_id": "uuid-maya"},
- {"id": "entity-alex", "name": "Alex", "entity_id": "uuid-alex"},
- {"id": "entity-john", "name": "John", "entity_id": "uuid-john"}, # This one will be kept
- ]
-
- # Models to remove (evaluate_emergent_models would return these)
- models_to_remove = ["entity-maya", "entity-alex"]
-
- # Build model_id -> entity_id mapping (this is what the fix does)
- model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent}
-
- # Track removed entity_ids
- removed_entity_ids: set[str] = set()
- for model_id in models_to_remove:
- entity_id = model_to_entity.get(model_id)
- if entity_id:
- removed_entity_ids.add(str(entity_id))
-
- # Verify we tracked the right entity_ids
- assert removed_entity_ids == {"uuid-maya", "uuid-alex"}
-
- # Now simulate candidates that were detected (includes removed entities)
- candidates = [
- EmergentCandidate(
- name="Maya", entity_id="uuid-maya", detection_method="named_entity", mention_count=10
- ),
- EmergentCandidate(
- name="Alex", entity_id="uuid-alex", detection_method="named_entity", mention_count=8
- ),
- EmergentCandidate(
- name="NewPerson", entity_id="uuid-new", detection_method="named_entity", mention_count=5
- ),
- ]
-
- # Filter out candidates whose entity was just removed (the fix)
- filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
-
- # Only NewPerson should remain - Maya and Alex were removed and should not be re-promoted
- assert len(filtered_candidates) == 1
- assert filtered_candidates[0].name == "NewPerson"
- assert filtered_candidates[0].entity_id == "uuid-new"
-
- async def test_candidates_without_matching_removal_are_kept(self):
- """Test that candidates not in the removed set are still promoted."""
- from hindsight_api.engine.mental_models.models import EmergentCandidate
-
- # No models removed
- removed_entity_ids: set[str] = set()
-
- candidates = [
- EmergentCandidate(
- name="Alice", entity_id="uuid-alice", detection_method="named_entity", mention_count=10
- ),
- EmergentCandidate(
- name="Bob", entity_id="uuid-bob", detection_method="named_entity", mention_count=8
- ),
- ]
-
- # Filter (should keep all since nothing was removed)
- filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
-
- assert len(filtered_candidates) == 2
- assert {c.name for c in filtered_candidates} == {"Alice", "Bob"}
-
- async def test_partial_removal_keeps_other_candidates(self):
- """Test that only removed entities are excluded, others pass through."""
- from hindsight_api.engine.mental_models.models import EmergentCandidate
-
- # Only one entity removed
- removed_entity_ids = {"uuid-removed"}
-
- candidates = [
- EmergentCandidate(
- name="Removed", entity_id="uuid-removed", detection_method="named_entity", mention_count=10
- ),
- EmergentCandidate(
- name="Kept1", entity_id="uuid-kept1", detection_method="named_entity", mention_count=8
- ),
- EmergentCandidate(
- name="Kept2", entity_id="uuid-kept2", detection_method="named_entity", mention_count=5
- ),
- ]
-
- filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
-
- assert len(filtered_candidates) == 2
- assert {c.name for c in filtered_candidates} == {"Kept1", "Kept2"}
diff --git a/hindsight-api/tests/test_extensions.py b/hindsight-api/tests/test_extensions.py
index 1b7bf103..88a2f5d3 100644
--- a/hindsight-api/tests/test_extensions.py
+++ b/hindsight-api/tests/test_extensions.py
@@ -17,8 +17,6 @@ from hindsight_api.extensions import (
RecallResult,
ReflectContext,
ReflectResultContext,
- RefreshMentalModelContext,
- RefreshMentalModelResult,
RequestContext,
RetainContext,
RetainResult,
@@ -95,7 +93,6 @@ class RateLimitingValidator(OperationValidatorExtension):
self.retain_counts: dict[str, int] = defaultdict(int)
self.recall_counts: dict[str, int] = defaultdict(int)
self.reflect_counts: dict[str, int] = defaultdict(int)
- self.refresh_mental_model_counts: dict[str, int] = defaultdict(int)
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.retain_counts[ctx.bank_id] += 1
@@ -121,16 +118,6 @@ class RateLimitingValidator(OperationValidatorExtension):
)
return ValidationResult.accept()
- async def validate_refresh_mental_model(
- self, ctx: RefreshMentalModelContext
- ) -> ValidationResult:
- self.refresh_mental_model_counts[ctx.bank_id] += 1
- if self.refresh_mental_model_counts[ctx.bank_id] > self.max_attempts:
- return ValidationResult.reject(
- f"Refresh mental model limit exceeded for bank {ctx.bank_id}"
- )
- return ValidationResult.accept()
-
class TrackingValidator(OperationValidatorExtension):
"""
@@ -145,12 +132,10 @@ class TrackingValidator(OperationValidatorExtension):
self.pre_retain_calls: list[RetainContext] = []
self.pre_recall_calls: list[RecallContext] = []
self.pre_reflect_calls: list[ReflectContext] = []
- self.pre_refresh_mental_model_calls: list[RefreshMentalModelContext] = []
# Post-hook tracking
self.post_retain_calls: list[RetainResult] = []
self.post_recall_calls: list[RecallResult] = []
self.post_reflect_calls: list[ReflectResultContext] = []
- self.post_refresh_mental_model_calls: list[RefreshMentalModelResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.pre_retain_calls.append(ctx)
@@ -164,12 +149,6 @@ class TrackingValidator(OperationValidatorExtension):
self.pre_reflect_calls.append(ctx)
return ValidationResult.accept()
- async def validate_refresh_mental_model(
- self, ctx: RefreshMentalModelContext
- ) -> ValidationResult:
- self.pre_refresh_mental_model_calls.append(ctx)
- return ValidationResult.accept()
-
async def on_retain_complete(self, result: RetainResult) -> None:
self.post_retain_calls.append(result)
@@ -179,11 +158,6 @@ class TrackingValidator(OperationValidatorExtension):
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
self.post_reflect_calls.append(result)
- async def on_refresh_mental_model_complete(
- self, result: RefreshMentalModelResult
- ) -> None:
- self.post_refresh_mental_model_calls.append(result)
-
class TestMemoryEngineValidation:
"""Tests for validation integration with MemoryEngine.
@@ -541,105 +515,6 @@ class TestOperationHooksParameters:
assert len(validator.pre_recall_calls) == 1
assert len(validator.post_recall_calls) == 1
- @pytest.mark.asyncio
- async def test_refresh_mental_model_pre_hook_receives_all_parameters(
- self, memory_with_tracking_validator
- ):
- """Pre-refresh-mental-model hook receives all user-provided parameters."""
- import uuid
-
- memory, validator = memory_with_tracking_validator
- bank_id = f"test-refresh-mm-params-{uuid.uuid4().hex[:8]}"
- ctx = RequestContext(api_key="test-key")
-
- # Create bank first (get_bank_profile auto-creates if needed)
- await memory.get_bank_profile(bank_id, request_context=ctx)
-
- # Create a pinned mental model
- model = await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Model",
- description="Test description",
- subtype="pinned",
- request_context=ctx,
- )
-
- assert model is not None
- model_id = model["id"]
-
- # Attempt to refresh (may not actually refresh if no data, but hook should be called)
- try:
- await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=ctx,
- )
- except Exception:
- pass # May fail if no data
-
- # Check pre-hook was called
- assert len(validator.pre_refresh_mental_model_calls) == 1
- pre_ctx = validator.pre_refresh_mental_model_calls[0]
- assert pre_ctx.bank_id == bank_id
- assert pre_ctx.model_id == model_id
- assert pre_ctx.request_context == ctx
-
- @pytest.mark.asyncio
- async def test_refresh_mental_model_post_hook_receives_token_usage(
- self, memory_with_tracking_validator
- ):
- """Post-refresh-mental-model hook receives token usage information."""
- import uuid
-
- memory, validator = memory_with_tracking_validator
- bank_id = f"test-refresh-mm-tokens-{uuid.uuid4().hex[:8]}"
- ctx = RequestContext(api_key="test-key")
-
- # Store some content first
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is a software engineer who works on machine learning."},
- {"content": "Alice enjoys hiking and outdoor activities on weekends."},
- {"content": "Alice has been working at the company for 5 years."},
- ],
- request_context=ctx,
- )
-
- # Create a pinned mental model
- model = await memory.create_mental_model(
- bank_id=bank_id,
- name="Alice Profile",
- description="Profile of Alice including work and hobbies",
- subtype="pinned",
- request_context=ctx,
- )
-
- if model:
- model_id = model["id"]
-
- # Refresh the mental model
- result = await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=ctx,
- )
-
- # Check post-hook was called with token usage
- if validator.post_refresh_mental_model_calls:
- post_result = validator.post_refresh_mental_model_calls[0]
- assert post_result.bank_id == bank_id
- assert post_result.model_id == model_id
- assert post_result.request_context == ctx
- assert post_result.success is True
- assert post_result.error is None
-
- # Token usage should be populated (may be 0 if refresh was skipped)
- assert post_result.total_tokens >= 0
- assert post_result.input_tokens >= 0
- assert post_result.output_tokens >= 0
- assert post_result.duration_ms >= 0
-
class TestTenantExtension:
"""Tests for TenantExtension and ApiKeyTenantExtension."""
diff --git a/hindsight-api/tests/test_llm_tools.py b/hindsight-api/tests/test_llm_tools.py
index 6947eb2e..400c52a4 100644
--- a/hindsight-api/tests/test_llm_tools.py
+++ b/hindsight-api/tests/test_llm_tools.py
@@ -241,24 +241,27 @@ class TestReflectToolSchemas:
tools = get_reflect_tools()
tool_names = [t["function"]["name"] for t in tools]
- assert "list_mental_models" in tool_names
- assert "get_mental_model" in tool_names
+ assert "search_reflections" in tool_names
+ assert "search_mental_models" in tool_names
assert "recall" in tool_names
- assert "learn" in tool_names
assert "expand" in tool_names
assert "done" in tool_names
- def test_get_reflect_tools_without_learn(self):
- """Test getting reflect tools without learn."""
+ def test_get_reflect_tools_with_directives(self):
+ """Test getting reflect tools with directive rules."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
- tools = get_reflect_tools(enable_learn=False)
+ tools = get_reflect_tools(directive_rules=["Always respond in French"])
tool_names = [t["function"]["name"] for t in tools]
- assert "learn" not in tool_names
assert "recall" in tool_names
assert "done" in tool_names
+ # Done tool should have directive_compliance field when directives are present
+ done_tool = next(t for t in tools if t["function"]["name"] == "done")
+ params = done_tool["function"]["parameters"]["properties"]
+ assert "directive_compliance" in params
+
def test_get_reflect_tools_answer_mode(self):
"""Test getting reflect tools with answer output mode."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
@@ -270,7 +273,8 @@ class TestReflectToolSchemas:
assert "answer" in params
assert "memory_ids" in params
- assert "model_ids" in params
+ assert "mental_model_ids" in params
+ assert "reflection_ids" in params
class TestLLMToolCallResult:
diff --git a/hindsight-api/tests/test_main_module.py b/hindsight-api/tests/test_main_module.py
index 7faccae7..0923fad0 100644
--- a/hindsight-api/tests/test_main_module.py
+++ b/hindsight-api/tests/test_main_module.py
@@ -363,7 +363,6 @@ from hindsight_api.extensions import (
RetainContext,
RecallContext,
ReflectContext,
- RefreshMentalModelContext,
)
@@ -395,6 +394,3 @@ class MockOperationValidator(OperationValidatorExtension):
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
-
- async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
- return ValidationResult.accept()
diff --git a/hindsight-api/tests/test_mental_models.py b/hindsight-api/tests/test_mental_models.py
index 14f95240..c391c34e 100644
--- a/hindsight-api/tests/test_mental_models.py
+++ b/hindsight-api/tests/test_mental_models.py
@@ -1,4 +1,8 @@
-"""Tests for mental model functionality (v4 system)."""
+"""Tests for directive functionality.
+
+Directives are hard rules injected into prompts.
+They are stored in the 'directives' table.
+"""
import uuid
@@ -8,20 +12,16 @@ from hindsight_api.engine.memory_engine import MemoryEngine
@pytest.fixture
-async def memory_with_mission(memory: MemoryEngine, request_context):
- """Memory engine with a bank that has a mission set.
+async def memory_with_bank(memory: MemoryEngine, request_context):
+ """Memory engine with a bank that has some data.
Uses a unique bank_id to avoid conflicts between parallel tests.
"""
# Use unique bank_id to avoid conflicts between parallel tests
- bank_id = f"test-mental-models-{uuid.uuid4().hex[:8]}"
+ bank_id = f"test-directives-{uuid.uuid4().hex[:8]}"
- # Set up the bank with a mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Be a PM for the engineering team",
- request_context=request_context,
- )
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some test data
await memory.retain_batch_async(
@@ -30,8 +30,6 @@ async def memory_with_mission(memory: MemoryEngine, request_context):
{"content": "The team has daily standups at 9am where everyone shares their progress."},
{"content": "Alice is the frontend engineer and specializes in React."},
{"content": "Bob is the backend engineer and owns the API services."},
- {"content": "Sprint retrospectives happen every two weeks to discuss improvements."},
- {"content": "John is the tech lead and makes final decisions on architecture."},
],
request_context=request_context,
)
@@ -70,340 +68,257 @@ class TestBankMission:
await memory.delete_bank(bank_id, request_context=request_context)
-class TestRefreshMentalModels:
- """Test the main refresh_mental_models flow."""
+class TestDirectives:
+ """Test directive functionality."""
- async def test_refresh_creates_structural_models(self, memory_with_mission, request_context):
- """Test that refresh creates structural models from the mission."""
- memory, bank_id = memory_with_mission
-
- # Refresh mental models (async - returns operation_id)
- result = await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Check that we got an operation ID back
- assert "operation_id" in result
- assert result["status"] == "queued"
-
- # Wait for background task to complete
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert len(models) > 0
-
- # Check that structural models were created
- structural_models = [m for m in models if m["subtype"] == "structural"]
- assert len(structural_models) > 0
-
- # Check that models have the expected structure
- for model in models:
- assert "id" in model
- assert "name" in model
- assert "description" in model
- assert model["subtype"] in ["structural", "emergent"]
-
- async def test_refresh_without_mission_fails(self, memory: MemoryEngine, request_context):
- """Test that refresh fails when no mission is set."""
- bank_id = f"test-no-mission-refresh-{uuid.uuid4().hex[:8]}"
-
- # Add some data but don't set a mission
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the frontend engineer."},
- {"content": "Bob is the backend engineer."},
- ],
- request_context=request_context,
- )
-
- # Wait for any background tasks from retain to complete
- await memory.wait_for_background_tasks()
-
- # Refresh mental models should fail without a mission
- with pytest.raises(ValueError) as exc_info:
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert "no mission is set" in str(exc_info.value).lower()
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
-
-class TestMentalModelCRUD:
- """Test basic CRUD operations for mental models."""
-
- async def test_list_mental_models(self, memory_with_mission, request_context):
- """Test listing mental models."""
- memory, bank_id = memory_with_mission
-
- # Refresh to create models (async)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # List all models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert len(models) > 0
-
- # Test filtering by subtype
- structural_models = await memory.list_mental_models(
- bank_id=bank_id,
- subtype="structural",
- request_context=request_context,
- )
-
- assert all(m["subtype"] == "structural" for m in structural_models)
-
- async def test_get_mental_model(self, memory_with_mission, request_context):
- """Test getting a mental model by ID."""
- memory, bank_id = memory_with_mission
-
- # Refresh to create models (async)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Get one by ID
- model_id = models[0]["id"]
- model = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- assert model is not None
- assert model["id"] == model_id
-
- # Test non-existent
- not_found = await memory.get_mental_model(
- bank_id=bank_id,
- model_id="non-existent",
- request_context=request_context,
- )
- assert not_found is None
-
- async def test_delete_mental_model(self, memory_with_mission, request_context):
- """Test deleting a mental model."""
- memory, bank_id = memory_with_mission
-
- # Refresh to create models (async)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Delete one
- model_id = models[0]["id"]
- deleted = await memory.delete_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert deleted is True
-
- # Verify it's gone
- model = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert model is None
-
- # Delete non-existent returns False
- deleted_again = await memory.delete_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert deleted_again is False
-
- async def test_create_pinned_mental_model(self, memory: MemoryEngine, request_context):
- """Test creating a pinned mental model."""
- bank_id = f"test-pinned-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists by getting its profile (auto-creates if needed)
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a pinned mental model
- model = await memory.create_mental_model(
- bank_id=bank_id,
- name="Product Roadmap",
- description="Key product priorities and upcoming features",
- tags=["project-x"],
- request_context=request_context,
- )
-
- assert model["name"] == "Product Roadmap"
- assert model["description"] == "Key product priorities and upcoming features"
- assert model["subtype"] == "pinned"
- assert model["tags"] == ["project-x"]
- assert model["id"] == "pinned-product-roadmap"
-
- # Verify it can be retrieved
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=model["id"],
- request_context=request_context,
- )
- assert retrieved is not None
- assert retrieved["subtype"] == "pinned"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_create_pinned_model_duplicate_fails(self, memory: MemoryEngine, request_context):
- """Test that creating a duplicate pinned model fails."""
- bank_id = f"test-pinned-dup-{uuid.uuid4().hex[:8]}"
+ async def test_create_directive(self, memory: MemoryEngine, request_context):
+ """Test creating a directive."""
+ bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
- # Create first model
- await memory.create_mental_model(
+ # Create a directive
+ directive = await memory.create_directive(
bank_id=bank_id,
- name="Test Model",
- description="First model",
+ name="Competitor Policy",
+ content="Never mention competitor product names directly. If asked about competitors, redirect to our features.",
request_context=request_context,
)
- # Try to create duplicate
- with pytest.raises(ValueError) as exc_info:
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Model",
- description="Second model",
- request_context=request_context,
- )
-
- assert "already exists" in str(exc_info.value).lower()
+ assert directive["name"] == "Competitor Policy"
+ assert "Never mention competitor" in directive["content"]
+ assert directive["is_active"] is True
+ assert directive["priority"] == 0
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
- async def test_pinned_models_survive_refresh(self, memory: MemoryEngine, request_context):
- """Test that pinned models are not deleted during refresh."""
- bank_id = f"test-pinned-refresh-{uuid.uuid4().hex[:8]}"
+ async def test_directive_crud(self, memory: MemoryEngine, request_context):
+ """Test basic CRUD operations for directives."""
+ bank_id = f"test-directive-crud-{uuid.uuid4().hex[:8]}"
- # Set a mission
- await memory.set_bank_mission(
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create
+ directive = await memory.create_directive(
bank_id=bank_id,
- mission="Track customer feedback",
+ name="Test Directive",
+ content="Follow this rule",
request_context=request_context,
)
+ directive_id = directive["id"]
- # Create a pinned model
- pinned_model = await memory.create_mental_model(
+ # Read
+ retrieved = await memory.get_directive(
bank_id=bank_id,
- name="Key Customers",
- description="Important customers to track",
- request_context=request_context,
- )
-
- # Refresh mental models
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Verify pinned model still exists
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=pinned_model["id"],
+ directive_id=directive_id,
request_context=request_context,
)
assert retrieved is not None
- assert retrieved["subtype"] == "pinned"
- assert retrieved["name"] == "Key Customers"
+ assert retrieved["name"] == "Test Directive"
+ assert retrieved["content"] == "Follow this rule"
+
+ # List
+ directives = await memory.list_directives(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(directives) == 1
+ assert directives[0]["id"] == directive_id
+
+ # Update
+ updated = await memory.update_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ content="Updated rule content",
+ request_context=request_context,
+ )
+ assert updated["content"] == "Updated rule content"
+
+ # Delete
+ deleted = await memory.delete_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ assert deleted is True
+
+ # Verify deletion
+ retrieved_after = await memory.get_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ assert retrieved_after is None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ async def test_directive_priority(self, memory: MemoryEngine, request_context):
+ """Test that directive priority works correctly."""
+ bank_id = f"test-directive-priority-{uuid.uuid4().hex[:8]}"
+
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create directives with different priorities
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Low Priority",
+ content="Low priority rule",
+ priority=1,
+ request_context=request_context,
+ )
+
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="High Priority",
+ content="High priority rule",
+ priority=10,
+ request_context=request_context,
+ )
+
+ # List should order by priority (desc)
+ directives = await memory.list_directives(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(directives) == 2
+ assert directives[0]["name"] == "High Priority"
+ assert directives[1]["name"] == "Low Priority"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ async def test_directive_is_active(self, memory: MemoryEngine, request_context):
+ """Test that inactive directives are filtered by default."""
+ bank_id = f"test-directive-active-{uuid.uuid4().hex[:8]}"
+
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create active and inactive directives
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Active Rule",
+ content="This is active",
+ is_active=True,
+ request_context=request_context,
+ )
+
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Inactive Rule",
+ content="This is inactive",
+ is_active=False,
+ request_context=request_context,
+ )
+
+ # List active only (default)
+ active_directives = await memory.list_directives(
+ bank_id=bank_id,
+ active_only=True,
+ request_context=request_context,
+ )
+ assert len(active_directives) == 1
+ assert active_directives[0]["name"] == "Active Rule"
+
+ # List all
+ all_directives = await memory.list_directives(
+ bank_id=bank_id,
+ active_only=False,
+ request_context=request_context,
+ )
+ assert len(all_directives) == 2
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
-class TestMentalModelRefresh:
- """Test mental model summary refresh functionality."""
+class TestDirectiveTags:
+ """Test tags functionality for directives."""
- async def test_refresh_creates_models_with_summaries(self, memory_with_mission, request_context):
- """Test that refresh_mental_models creates models and generates summaries."""
- memory, bank_id = memory_with_mission
+ async def test_directive_with_tags(self, memory: MemoryEngine, request_context):
+ """Test creating a directive with tags."""
+ bank_id = f"test-directive-tags-{uuid.uuid4().hex[:8]}"
- # Refresh mental models (async - creates models and generates summaries)
- result = await memory.refresh_mental_models(
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create a directive with tags
+ directive = await memory.create_directive(
bank_id=bank_id,
+ name="Tagged Rule",
+ content="Follow this rule",
+ tags=["project-a", "team-x"],
request_context=request_context,
)
- assert "operation_id" in result
- assert result["status"] == "queued"
+ assert directive["tags"] == ["project-a", "team-x"]
- # Wait for background task to complete (includes summary generation)
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
+ # Retrieve and verify tags
+ retrieved = await memory.get_directive(
bank_id=bank_id,
+ directive_id=directive["id"],
+ request_context=request_context,
+ )
+ assert retrieved["tags"] == ["project-a", "team-x"]
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ async def test_list_directives_by_tags(self, memory: MemoryEngine, request_context):
+ """Test listing directives filtered by tags."""
+ bank_id = f"test-directive-tags-list-{uuid.uuid4().hex[:8]}"
+
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create directives with different tags
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Rule A",
+ content="Rule for project A",
+ tags=["project-a"],
request_context=request_context,
)
- assert len(models) > 0
-
- # After async refresh completes, models should have summaries generated
- for model in models:
- assert "id" in model
- assert "name" in model
- # Summaries should be generated now (unless no relevant facts found)
- # We don't strictly assert on summary presence since it depends on data
-
- async def test_refresh_nonexistent_mental_model(self, memory: MemoryEngine, request_context):
- """Test refreshing a non-existent mental model returns None."""
- bank_id = f"test-refresh-noexist-{uuid.uuid4().hex[:8]}"
-
- result = await memory.refresh_mental_model(
+ await memory.create_directive(
bank_id=bank_id,
- model_id="does-not-exist",
+ name="Rule B",
+ content="Rule for project B",
+ tags=["project-b"],
request_context=request_context,
)
- assert result is None
+ # List all
+ all_directives = await memory.list_directives(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(all_directives) == 2
+
+ # Filter by project-a tag
+ filtered = await memory.list_directives(
+ bank_id=bank_id,
+ tags=["project-a"],
+ request_context=request_context,
+ )
+ assert len(filtered) == 1
+ assert filtered[0]["name"] == "Rule A"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
class TestReflect:
- """Test reflect endpoint with mental models."""
+ """Test reflect endpoint."""
- async def test_reflect_basic(self, memory_with_mission, request_context):
- """Test basic reflect query - reflect works even without mental models."""
- memory, bank_id = memory_with_mission
+ async def test_reflect_basic(self, memory_with_bank, request_context):
+ """Test basic reflect query works."""
+ memory, bank_id = memory_with_bank
# Run a reflect query
result = await memory.reflect_async(
@@ -416,582 +331,6 @@ class TestReflect:
assert len(result.text) > 0
-class TestMentalModelLearnTool:
- """Test mental model learn tool - creates placeholders with background generation."""
-
- async def test_learn_creates_placeholder(self, memory: MemoryEngine, request_context):
- """Test that learn tool creates a placeholder mental model without observations."""
- bank_id = f"test-source-facts-{uuid.uuid4().hex[:8]}"
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the team lead."},
- {"content": "Bob is the engineer."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Directly use the learn tool to create a mental model placeholder
- from hindsight_api.engine.reflect.models import MentalModelInput
- from hindsight_api.engine.reflect.tools import tool_learn
-
- input_model = MentalModelInput(
- name="Team Members",
- description="Key team members and their roles",
- )
-
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- result = await tool_learn(conn, bank_id, input_model)
-
- assert result["status"] == "created"
- assert result["model_id"] == "team-members"
- assert result["name"] == "Team Members"
- assert result["pending_generation"] is True
-
- # Verify placeholder was stored in database with empty observations
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- row = await conn.fetchrow(
- "SELECT subtype, name, description, observations FROM mental_models WHERE id = $1 AND bank_id = $2",
- result["model_id"],
- bank_id,
- )
-
- assert row is not None
- assert row["subtype"] == "learned"
- assert row["name"] == "Team Members"
- assert row["description"] == "Key team members and their roles"
- # Observations should be empty - will be generated in background
- observations_data = row["observations"]
- # Handle both string and dict representations
- if isinstance(observations_data, str):
- import json
- observations_data = json.loads(observations_data) if observations_data else {}
- assert observations_data == {} or observations_data is None
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_learn_update_description(self, memory: MemoryEngine, request_context):
- """Test that updating a mental model updates the description."""
- bank_id = f"test-merge-facts-{uuid.uuid4().hex[:8]}"
-
- # Create bank by retaining some data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Test data"}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- from hindsight_api.engine.reflect.models import MentalModelInput
- from hindsight_api.engine.reflect.tools import tool_learn
-
- # First create a placeholder
- input_model = MentalModelInput(
- name="Team Members",
- description="Initial description",
- )
-
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- result1 = await tool_learn(conn, bank_id, input_model)
-
- assert result1["status"] == "created"
- assert result1["pending_generation"] is True
-
- # Now update with new description
- input_model2 = MentalModelInput(
- name="Team Members", # Same name = same ID
- description="Updated description with more context",
- )
-
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- result2 = await tool_learn(conn, bank_id, input_model2)
-
- assert result2["status"] == "updated"
- assert result2["model_id"] == "team-members"
-
- # Verify description was updated in database
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- row = await conn.fetchrow(
- "SELECT description FROM mental_models WHERE id = $1 AND bank_id = $2",
- result1["model_id"],
- bank_id,
- )
-
- assert row is not None
- assert row["description"] == "Updated description with more context"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
-
-class TestMentalModelTags:
- """Test mental model tags functionality."""
-
- @pytest.fixture
- async def memory_with_mission_and_tags(self, memory: MemoryEngine, request_context):
- """Memory engine with a bank that has a mission set and tagged content."""
- bank_id = f"test-mm-tags-{uuid.uuid4().hex[:8]}"
-
- # Set up the bank with a mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Be a PM for the engineering team",
- request_context=request_context,
- )
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the frontend engineer."},
- {"content": "Bob is the backend engineer."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- yield memory, bank_id
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_refresh_creates_models_with_tags(self, memory_with_mission_and_tags, request_context):
- """Test that refresh_mental_models creates models with specified tags."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Refresh mental models with tags
- result = await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["project-alpha", "sprint-1"],
- request_context=request_context,
- )
-
- assert "operation_id" in result
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert len(models) > 0
-
- # All models should have the tags we specified
- for model in models:
- assert "tags" in model
- assert "project-alpha" in model["tags"]
- assert "sprint-1" in model["tags"]
-
- async def test_list_mental_models_filters_by_tags(self, memory_with_mission_and_tags, request_context):
- """Test that list_mental_models correctly filters by tags."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Create models with different tags
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["project-alpha"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get all models
- all_models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(all_models) > 0
-
- # Filter by tags - should return models with matching tags
- filtered_models = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-alpha"],
- request_context=request_context,
- )
- assert len(filtered_models) == len(all_models) # All models have this tag
-
- # Filter by non-existent tag - should only return untagged models (none here)
- # But since all models have tags, and the filter includes untagged,
- # we need to test with a mix
- empty_filtered = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["non-existent-tag"],
- request_context=request_context,
- )
- # Should return empty since no models are untagged and none match
- # Actually, the logic includes untagged models, so let's verify the behavior
- # All our models have tags, so only checking for non-existent tag
- # should return nothing (since none match and none are untagged)
-
- async def test_untagged_models_included_in_filter(self, memory: MemoryEngine, request_context):
- """Test that untagged mental models are always included when filtering."""
- bank_id = f"test-untagged-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Track projects",
- request_context=request_context,
- )
-
- # Add some data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Project Alpha is important."}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # First refresh without tags (creates untagged models)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context, # No tags
- )
- await memory.wait_for_background_tasks()
-
- # Get all models (should be untagged)
- all_models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- if len(all_models) > 0:
- # Verify models are untagged
- for model in all_models:
- assert model.get("tags", []) == []
-
- # Filter by any tag - untagged models should still be included
- filtered_models = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["some-tag"],
- request_context=request_context,
- )
- # Untagged models should be included in the results
- assert len(filtered_models) == len(all_models)
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_tags_match_any(self, memory: MemoryEngine, request_context):
- """Test tags_match='any' returns models with at least one matching tag."""
- bank_id = f"test-tags-any-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Track projects",
- request_context=request_context,
- )
-
- # Add data and create models with tags
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Alice works on frontend."}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["tag-a", "tag-b"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Filter with tags_match='any' - should match if any tag matches
- models = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["tag-a", "tag-c"], # tag-a matches, tag-c doesn't
- tags_match="any",
- request_context=request_context,
- )
-
- # Models with tag-a should be included
- for model in models:
- if model.get("tags"):
- # At least one of the filter tags should be in the model tags
- # OR model is untagged
- assert (
- any(t in model["tags"] for t in ["tag-a", "tag-c"])
- or model["tags"] == []
- )
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_reflect_with_tags_filter(self, memory_with_mission_and_tags, request_context):
- """Test that reflect filters memories by tags."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Create mental models with tags
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["project-x"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Reflect with matching tags
- result = await memory.reflect_async(
- bank_id=bank_id,
- query="Who are the engineers?",
- tags=["project-x"],
- request_context=request_context,
- )
-
- assert result.text is not None
- assert len(result.text) > 0
-
- # Reflect with non-matching tags - should still work
- result2 = await memory.reflect_async(
- bank_id=bank_id,
- query="Who are the engineers?",
- tags=["different-project"],
- request_context=request_context,
- )
-
- assert result2.text is not None
-
- async def test_mental_model_response_includes_tags(self, memory_with_mission_and_tags, request_context):
- """Test that mental model responses include the tags field."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Create models with tags
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["test-tag"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Verify tags field is present in response
- for model in models:
- assert "tags" in model
- assert isinstance(model["tags"], list)
-
- # Get single model
- if models:
- model = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=models[0]["id"],
- request_context=request_context,
- )
- assert "tags" in model
- assert isinstance(model["tags"], list)
-
-
-class TestDirectives:
- """Test directive mental model functionality."""
-
- async def test_create_directive(self, memory: MemoryEngine, request_context):
- """Test creating a directive mental model with user-provided observations."""
- bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a directive with observations
- model = await memory.create_mental_model(
- bank_id=bank_id,
- name="Competitor Policy",
- description="Rules about mentioning competitors",
- subtype="directive",
- observations=[
- {"title": "Never mention", "content": "Never mention competitor product names directly"},
- {"title": "Redirect", "content": "If asked about competitors, redirect to our features"},
- ],
- request_context=request_context,
- )
-
- assert model["name"] == "Competitor Policy"
- assert model["description"] == "Rules about mentioning competitors"
- assert model["subtype"] == "directive"
- assert len(model["observations"]) == 2
- assert model["observations"][0].title == "Never mention"
- assert model["observations"][0].content == "Never mention competitor product names directly"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_included_in_list(self, memory: MemoryEngine, request_context):
- """Test that directives are included in list_mental_models for admin visibility."""
- bank_id = f"test-directive-list-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Test mission",
- request_context=request_context,
- )
-
- # Create a directive
- directive = await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Directive",
- description="A test directive",
- subtype="directive",
- observations=[{"title": "Rule", "content": "Follow this rule"}],
- request_context=request_context,
- )
-
- # Create a pinned model
- pinned = await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Pinned",
- description="A test pinned model",
- request_context=request_context,
- )
-
- # List without subtype filter - both should appear
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Both should appear (directives included in API listing for admin visibility)
- model_ids = [m["id"] for m in models]
- assert pinned["id"] in model_ids
- assert directive["id"] in model_ids
-
- # List with directive subtype filter - should find only directive
- directives = await memory.list_mental_models(
- bank_id=bank_id,
- subtype="directive",
- request_context=request_context,
- )
- assert len(directives) == 1
- assert directives[0]["id"] == directive["id"]
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_get_includes_observations(self, memory: MemoryEngine, request_context):
- """Test that getting a directive returns its user-provided observations."""
- bank_id = f"test-directive-get-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a directive with observations
- created = await memory.create_mental_model(
- bank_id=bank_id,
- name="Meeting Rules",
- description="Rules for scheduling meetings",
- subtype="directive",
- observations=[
- {"title": "No mornings", "content": "Never schedule meetings before noon"},
- {"title": "Max duration", "content": "Meetings should be 30 minutes max"},
- ],
- request_context=request_context,
- )
-
- # Get the directive
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=created["id"],
- request_context=request_context,
- )
-
- assert retrieved is not None
- assert retrieved["subtype"] == "directive"
- assert len(retrieved["observations"]) == 2
- assert retrieved["observations"][0].title == "No mornings"
- assert retrieved["observations"][1].title == "Max duration"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_survives_refresh(self, memory: MemoryEngine, request_context):
- """Test that directives are not modified during refresh_mental_models."""
- bank_id = f"test-directive-refresh-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Test mission",
- request_context=request_context,
- )
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Alice is the engineer."}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Create a directive
- directive = await memory.create_mental_model(
- bank_id=bank_id,
- name="Important Rule",
- description="A critical rule",
- subtype="directive",
- observations=[{"title": "Rule 1", "content": "Always follow this rule"}],
- request_context=request_context,
- )
-
- # Refresh mental models
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Directive should still exist with same observations
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=directive["id"],
- request_context=request_context,
- )
-
- assert retrieved is not None
- assert retrieved["subtype"] == "directive"
- assert len(retrieved["observations"]) == 1
- assert retrieved["observations"][0].title == "Rule 1"
- assert retrieved["observations"][0].content == "Always follow this rule"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_requires_observations(self, memory: MemoryEngine, request_context):
- """Test that creating a directive without observations fails."""
- bank_id = f"test-directive-no-obs-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Try to create directive without observations
- with pytest.raises(ValueError) as exc_info:
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Bad Directive",
- description="A directive without observations",
- subtype="directive",
- # No observations provided
- request_context=request_context,
- )
-
- assert "observations" in str(exc_info.value).lower()
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
-
class TestDirectivesInReflect:
"""Test that directives are followed during reflect operations."""
@@ -1015,17 +354,10 @@ class TestDirectivesInReflect:
await memory.wait_for_background_tasks()
# Create a directive to always respond in French
- await memory.create_mental_model(
+ await memory.create_directive(
bank_id=bank_id,
name="Language Policy",
- description="Rules about language usage",
- subtype="directive",
- observations=[
- {
- "title": "French Only",
- "content": "ALWAYS respond in French language. Never respond in English.",
- },
- ],
+ content="ALWAYS respond in French language. Never respond in English.",
request_context=request_context,
)
@@ -1067,165 +399,6 @@ class TestDirectivesInReflect:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
-class TestMentalModelTagsFiltering:
- """Test tags filtering for mental models (all types)."""
-
- async def test_tags_match_any_includes_untagged(self, memory: MemoryEngine, request_context):
- """Test that 'any' tags_match mode includes untagged mental models."""
- bank_id = f"test-mm-tags-any-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create an UNTAGGED pinned model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Global Model",
- description="A global mental model",
- subtype="pinned",
- tags=[], # No tags - should be included with "any" mode
- request_context=request_context,
- )
-
- # Test 1: list_mental_models with tags and tags_match="any" should include untagged
- models_any = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["some-tag"],
- tags_match="any", # Should include untagged
- request_context=request_context,
- )
- assert len(models_any) == 1, f"Expected untagged model with 'any' mode, got {len(models_any)}"
-
- # Test 2: list_mental_models with tags and tags_match="any_strict" should exclude untagged
- models_strict = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["some-tag"],
- tags_match="any_strict", # Should exclude untagged
- request_context=request_context,
- )
- assert len(models_strict) == 0, f"Expected no models with 'any_strict' mode, got {len(models_strict)}"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_tags_match_strict_modes(self, memory: MemoryEngine, request_context):
- """Test that strict modes only include mental models with matching tags."""
- bank_id = f"test-mm-tags-strict-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a TAGGED pinned model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Tagged Model",
- description="A tagged mental model",
- subtype="pinned",
- tags=["project-a"],
- request_context=request_context,
- )
-
- # Create an UNTAGGED pinned model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Untagged Model",
- description="An untagged mental model",
- subtype="pinned",
- tags=[], # No tags
- request_context=request_context,
- )
-
- # Test 1: any_strict with matching tag - should get ONLY the tagged model
- models_match = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a"],
- tags_match="any_strict",
- request_context=request_context,
- )
- assert len(models_match) == 1, f"Expected 1 model with matching tag, got {len(models_match)}"
- assert models_match[0]["name"] == "Tagged Model"
-
- # Test 2: any_strict with different tag - should get NO models
- models_no_match = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-b"],
- tags_match="any_strict",
- request_context=request_context,
- )
- assert len(models_no_match) == 0, f"Expected no models with non-matching tag, got {len(models_no_match)}"
-
- # Test 3: any (non-strict) with any tag - should get BOTH models
- models_any = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a"],
- tags_match="any",
- request_context=request_context,
- )
- assert len(models_any) == 2, f"Expected 2 models with 'any' mode, got {len(models_any)}"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_tags_match_all_strict(self, memory: MemoryEngine, request_context):
- """Test that 'all_strict' requires ALL tags to be present."""
- bank_id = f"test-mm-tags-all-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a model with multiple tags
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Multi-Tag Model",
- description="Has project-a and project-b tags",
- subtype="pinned",
- tags=["project-a", "project-b"],
- request_context=request_context,
- )
-
- # Create a model with only one tag
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Single-Tag Model",
- description="Has only project-a tag",
- subtype="pinned",
- tags=["project-a"],
- request_context=request_context,
- )
-
- # Test 1: all_strict with both tags - should get ONLY the multi-tag model
- models_all = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a", "project-b"],
- tags_match="all_strict",
- request_context=request_context,
- )
- assert len(models_all) == 1, f"Expected 1 model with all tags, got {len(models_all)}"
- assert models_all[0]["name"] == "Multi-Tag Model"
-
- # Test 2: all (non-strict) with both tags - should include untagged too
- # Add an untagged model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Untagged Model",
- description="No tags",
- subtype="pinned",
- tags=[],
- request_context=request_context,
- )
-
- models_all_non_strict = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a", "project-b"],
- tags_match="all",
- request_context=request_context,
- )
- # Should get Multi-Tag Model + Untagged Model
- assert len(models_all_non_strict) == 2, f"Expected 2 models with 'all' mode, got {len(models_all_non_strict)}"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
class TestDirectivesPromptInjection:
"""Test that directives are properly injected into the system prompt."""
@@ -1237,43 +410,24 @@ class TestDirectivesPromptInjection:
result = build_directives_section([])
assert result == ""
- def test_build_directives_section_with_observations(self):
- """Test that directives with observations are formatted correctly."""
+ def test_build_directives_section_with_content(self):
+ """Test that directives with content are formatted correctly."""
from hindsight_api.engine.reflect.prompts import build_directives_section
directives = [
{
"name": "Competitor Policy",
- "observations": [
- {"title": "Never mention", "content": "Never mention competitor names"},
- {"title": "Redirect", "content": "Redirect to our features"},
- ],
+ "content": "Never mention competitor names. Redirect to our features.",
}
]
result = build_directives_section(directives)
assert "## DIRECTIVES (MANDATORY)" in result
- assert "**Never mention**: Never mention competitor names" in result
- assert "**Redirect**: Redirect to our features" in result
+ assert "Competitor Policy" in result
+ assert "Never mention competitor names" in result
assert "NEVER violate these directives" in result
- def test_build_directives_section_fallback_to_description(self):
- """Test that directives without observations fall back to description."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Simple Rule",
- "description": "Just a simple rule description",
- "observations": [],
- }
- ]
-
- result = build_directives_section(directives)
-
- assert "**Simple Rule**: Just a simple rule description" in result
-
def test_system_prompt_includes_directives(self):
"""Test that build_system_prompt_for_tools includes directives."""
from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools
@@ -1282,7 +436,7 @@ class TestDirectivesPromptInjection:
directives = [
{
"name": "Test Directive",
- "observations": [{"title": "Rule", "content": "Follow this rule"}],
+ "content": "Follow this rule",
}
]
@@ -1292,143 +446,8 @@ class TestDirectivesPromptInjection:
)
assert "## DIRECTIVES (MANDATORY)" in prompt
- assert "**Rule**: Follow this rule" in prompt
+ assert "Follow this rule" in prompt
# Directives should appear before CRITICAL RULES
directives_pos = prompt.find("## DIRECTIVES")
critical_rules_pos = prompt.find("## CRITICAL RULES")
assert directives_pos < critical_rules_pos
-
-
-class TestMentalModelVersioning:
- """Test mental model versioning functionality."""
-
- async def test_refresh_creates_version(self, memory_with_mission, request_context):
- """Test that refreshing a mental model creates a version entry."""
- memory, bank_id = memory_with_mission
-
- # First create a mental model via refresh_mental_models
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(models) > 0
-
- model_id = models[0]["id"]
-
- # Refresh the specific model to trigger versioning
- result = await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- assert result is not None
- # Version should be incremented
- assert result.get("version", 0) >= 1
-
- # Check version history
- versions = await memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- assert len(versions) >= 1
- assert versions[0]["version"] >= 1
- assert "created_at" in versions[0]
- assert "observation_count" in versions[0]
-
- async def test_get_specific_version(self, memory_with_mission, request_context):
- """Test retrieving a specific version of a mental model."""
- memory, bank_id = memory_with_mission
-
- # Create and refresh a mental model
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(models) > 0
-
- model_id = models[0]["id"]
-
- # Refresh to create version
- await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- # Get versions
- versions = await memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert len(versions) >= 1
-
- # Get specific version
- version_num = versions[0]["version"]
- version_data = await memory.get_mental_model_version(
- bank_id=bank_id,
- model_id=model_id,
- version=version_num,
- request_context=request_context,
- )
-
- assert version_data is not None
- assert version_data["version"] == version_num
- assert "observations" in version_data
-
- async def test_version_cleanup_keeps_max_versions(self, memory_with_mission, request_context):
- """Test that old versions are cleaned up when max is exceeded."""
- memory, bank_id = memory_with_mission
-
- # Create a mental model
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(models) > 0
-
- model_id = models[0]["id"]
-
- # Refresh multiple times to create versions
- for _ in range(3):
- await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- # Get versions - should have multiple but within max limit
- versions = await memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- # Should have versions (exact count depends on config, but at least some)
- assert len(versions) >= 1
- # Versions should be in descending order
- if len(versions) > 1:
- assert versions[0]["version"] > versions[1]["version"]
-
diff --git a/hindsight-api/tests/test_observation_trends.py b/hindsight-api/tests/test_observation_trends.py
deleted file mode 100644
index 7510ae1b..00000000
--- a/hindsight-api/tests/test_observation_trends.py
+++ /dev/null
@@ -1,405 +0,0 @@
-"""Tests for observation trend computation and evidence-grounded models."""
-
-from datetime import datetime, timedelta, timezone
-
-import pytest
-
-from hindsight_api.engine.reflect.observations import (
- CandidateObservation,
- Observation,
- ObservationEvidence,
- Trend,
- compute_trend,
- verify_evidence_quotes,
-)
-
-
-class TestComputeTrend:
- """Tests for the compute_trend function."""
-
- def test_empty_evidence_returns_stale(self):
- """No evidence should return STALE trend."""
- trend = compute_trend([])
- assert trend == Trend.STALE
-
- def test_all_recent_evidence_returns_new(self):
- """All evidence within recent window (30 days) should return NEW trend.
-
- Scenario: User just started using the app and mentioned they like coffee twice.
- Both mentions are within the last 2 weeks, so this is a NEW observation.
- """
- now = datetime.now(timezone.utc)
- evidence = [
- ObservationEvidence(
- memory_id="mem-coffee-morning",
- quote="I always start my day with a large black coffee",
- relevance="Shows preference for coffee and morning routine",
- timestamp=now - timedelta(days=5),
- ),
- ObservationEvidence(
- memory_id="mem-coffee-meeting",
- quote="grabbed coffee before the standup meeting",
- relevance="Confirms regular coffee consumption",
- timestamp=now - timedelta(days=10),
- ),
- ]
-
- trend = compute_trend(evidence, now=now)
- assert trend == Trend.NEW
-
- def test_no_recent_evidence_returns_stale(self):
- """No evidence in recent window should return STALE trend.
-
- Scenario: User mentioned running 3 months ago but hasn't mentioned it since.
- The observation about running as a hobby may no longer be accurate.
- """
- now = datetime.now(timezone.utc)
- evidence = [
- ObservationEvidence(
- memory_id="mem-running-march",
- quote="training for a half marathon in the spring",
- relevance="Shows interest in running",
- timestamp=now - timedelta(days=60),
- ),
- ObservationEvidence(
- memory_id="mem-running-feb",
- quote="went for a 10k run this morning",
- relevance="Active runner",
- timestamp=now - timedelta(days=100),
- ),
- ]
-
- trend = compute_trend(evidence, now=now)
- assert trend == Trend.STALE
-
- def test_stable_evidence_distribution(self):
- """Evidence spread evenly across time should return STABLE trend.
-
- Scenario: User has consistently mentioned working remotely over 4 months.
- Evidence is well-distributed, indicating a stable, ongoing preference.
- """
- now = datetime.now(timezone.utc)
- evidence = [
- # Recent (within 30 days)
- ObservationEvidence(
- memory_id="mem-remote-jan",
- quote="working from my home office today",
- relevance="Current remote work",
- timestamp=now - timedelta(days=5),
- ),
- ObservationEvidence(
- memory_id="mem-remote-dec",
- quote="the flexibility of remote work is great",
- relevance="Values remote work",
- timestamp=now - timedelta(days=15),
- ),
- # Middle period (30-90 days)
- ObservationEvidence(
- memory_id="mem-remote-nov",
- quote="set up a standing desk at home",
- relevance="Invested in home office",
- timestamp=now - timedelta(days=45),
- ),
- ObservationEvidence(
- memory_id="mem-remote-oct",
- quote="prefer async communication over meetings",
- relevance="Remote work style preference",
- timestamp=now - timedelta(days=60),
- ),
- # Older (90+ days)
- ObservationEvidence(
- memory_id="mem-remote-sep",
- quote="switched to fully remote last quarter",
- relevance="Original transition to remote",
- timestamp=now - timedelta(days=100),
- ),
- ObservationEvidence(
- memory_id="mem-remote-aug",
- quote="negotiated remote work in my new contract",
- relevance="Intentional choice for remote",
- timestamp=now - timedelta(days=120),
- ),
- ]
-
- trend = compute_trend(evidence, now=now)
- assert trend == Trend.STABLE
-
- def test_strengthening_trend(self):
- """Much more recent evidence than older should return STRENGTHENING trend.
-
- Scenario: User has been increasingly talking about learning Python recently
- after mentioning it once months ago. Interest appears to be growing.
- """
- now = datetime.now(timezone.utc)
- evidence = [
- # Lots of recent evidence - actively learning
- ObservationEvidence(
- memory_id="mem-python-project",
- quote="finished my first Python project - a web scraper",
- relevance="Completed Python project",
- timestamp=now - timedelta(days=2),
- ),
- ObservationEvidence(
- memory_id="mem-python-course",
- quote="halfway through the Python bootcamp",
- relevance="Active learning",
- timestamp=now - timedelta(days=5),
- ),
- ObservationEvidence(
- memory_id="mem-python-book",
- quote="reading Fluent Python, it's excellent",
- relevance="Deepening knowledge",
- timestamp=now - timedelta(days=10),
- ),
- ObservationEvidence(
- memory_id="mem-python-practice",
- quote="solved 50 LeetCode problems in Python",
- relevance="Practicing skills",
- timestamp=now - timedelta(days=15),
- ),
- ObservationEvidence(
- memory_id="mem-python-ide",
- quote="set up VS Code with all the Python extensions",
- relevance="Setting up environment",
- timestamp=now - timedelta(days=20),
- ),
- # Only one old mention - initial interest
- ObservationEvidence(
- memory_id="mem-python-start",
- quote="thinking about learning Python someday",
- relevance="Initial interest",
- timestamp=now - timedelta(days=100),
- ),
- ]
-
- trend = compute_trend(evidence, now=now)
- assert trend == Trend.STRENGTHENING
-
- def test_weakening_trend(self):
- """Much less recent evidence than older should return WEAKENING trend.
-
- Scenario: User was very active in a book club last year but mentions
- have tapered off. The observation about being a book club member
- may be becoming less relevant.
- """
- now = datetime.now(timezone.utc)
- evidence = [
- # Only one recent mention
- ObservationEvidence(
- memory_id="mem-book-recent",
- quote="haven't had time for book club lately",
- relevance="Reduced participation",
- timestamp=now - timedelta(days=10),
- ),
- # Lots of older evidence - was very active
- ObservationEvidence(
- memory_id="mem-book-aug",
- quote="hosting book club at my place next week",
- relevance="Active organizer",
- timestamp=now - timedelta(days=40),
- ),
- ObservationEvidence(
- memory_id="mem-book-july",
- quote="leading the discussion on 1984",
- relevance="Active participant",
- timestamp=now - timedelta(days=50),
- ),
- ObservationEvidence(
- memory_id="mem-book-june",
- quote="we picked The Midnight Library for June",
- relevance="Regular member",
- timestamp=now - timedelta(days=60),
- ),
- ObservationEvidence(
- memory_id="mem-book-may",
- quote="book club was amazing tonight",
- relevance="Enthusiastic member",
- timestamp=now - timedelta(days=100),
- ),
- ObservationEvidence(
- memory_id="mem-book-april",
- quote="joined a new book club in my neighborhood",
- relevance="Started participation",
- timestamp=now - timedelta(days=110),
- ),
- ObservationEvidence(
- memory_id="mem-book-march",
- quote="excited to finally join a book club",
- relevance="Initial enthusiasm",
- timestamp=now - timedelta(days=120),
- ),
- ]
-
- trend = compute_trend(evidence, now=now)
- assert trend == Trend.WEAKENING
-
-
-class TestObservationModel:
- """Tests for the Observation model."""
-
- def test_observation_computed_trend(self):
- """Observation should have computed trend property based on evidence."""
- now = datetime.now(timezone.utc)
- obs = Observation(
- title="Morning meeting preference",
- content="Prefers morning meetings over afternoon ones",
- evidence=[
- ObservationEvidence(
- memory_id="mem-morning-standup",
- quote="I'm most productive in morning meetings",
- relevance="Direct preference statement",
- timestamp=now - timedelta(days=5),
- ),
- ],
- created_at=now,
- )
-
- assert obs.trend == Trend.NEW
- assert obs.evidence_count == 1
-
- def test_observation_evidence_span(self):
- """Observation should compute evidence span correctly.
-
- The span shows the date range of supporting evidence, helping
- understand how long this pattern has been observed.
- """
- now = datetime.now(timezone.utc)
- old_time = now - timedelta(days=100)
- recent_time = now - timedelta(days=5)
-
- obs = Observation(
- title="Values work-life balance",
- content="Values work-life balance highly",
- evidence=[
- ObservationEvidence(
- memory_id="mem-balance-old",
- quote="turned down a promotion because of the hours",
- relevance="Prioritized balance over advancement",
- timestamp=old_time,
- ),
- ObservationEvidence(
- memory_id="mem-balance-recent",
- quote="always log off by 6pm no matter what",
- relevance="Maintains boundaries",
- timestamp=recent_time,
- ),
- ],
- created_at=now,
- )
-
- evidence_span = obs.evidence_span
- assert evidence_span["from"] == old_time.isoformat()
- assert evidence_span["to"] == recent_time.isoformat()
-
- def test_observation_empty_evidence_span(self):
- """Observation with no evidence should have null span."""
- obs = Observation(
- title="Test observation",
- content="Test observation without evidence",
- evidence=[],
- )
-
- evidence_span = obs.evidence_span
- assert evidence_span["from"] is None
- assert evidence_span["to"] is None
-
-
-class TestVerifyEvidenceQuotes:
- """Tests for evidence quote verification.
-
- This ensures the LLM isn't hallucinating quotes - every quote
- must actually appear in the source memory.
- """
-
- def test_valid_quotes(self):
- """Should return True when quotes exist in their source memories."""
- obs = Observation(
- title="Enjoys hiking",
- content="Enjoys hiking on weekends",
- evidence=[
- ObservationEvidence(
- memory_id="mem-hiking-trip",
- quote="went hiking at Mount Tam",
- relevance="Shows hiking activity",
- timestamp=datetime.now(timezone.utc),
- ),
- ],
- )
-
- memories = {
- "mem-hiking-trip": "Had a great Saturday - went hiking at Mount Tam with friends and saw amazing views."
- }
- is_valid, errors = verify_evidence_quotes(obs, memories)
-
- assert is_valid is True
- assert len(errors) == 0
-
- def test_invalid_quote(self):
- """Should return False when quote doesn't exist in memory.
-
- This catches LLM hallucinations where it fabricates quotes.
- """
- obs = Observation(
- title="Loves spicy food",
- content="Loves spicy food",
- evidence=[
- ObservationEvidence(
- memory_id="mem-dinner",
- quote="I love extra hot salsa",
- relevance="Shows spicy food preference",
- timestamp=datetime.now(timezone.utc),
- ),
- ],
- )
-
- memories = {"mem-dinner": "Had tacos for dinner. The guacamole was really fresh."}
- is_valid, errors = verify_evidence_quotes(obs, memories)
-
- assert is_valid is False
- assert len(errors) == 1
- assert "Quote not found" in errors[0]
-
- def test_missing_memory(self):
- """Should return False when referenced memory doesn't exist.
-
- This catches cases where the LLM references a memory ID that
- was never actually retrieved.
- """
- obs = Observation(
- title="Has a dog named Max",
- content="Has a dog named Max",
- evidence=[
- ObservationEvidence(
- memory_id="mem-pet-story",
- quote="took Max to the vet",
- relevance="Shows pet ownership",
- timestamp=datetime.now(timezone.utc),
- ),
- ],
- )
-
- memories = {"mem-different-id": "Some unrelated memory content"}
- is_valid, errors = verify_evidence_quotes(obs, memories)
-
- assert is_valid is False
- assert len(errors) == 1
- assert "not found" in errors[0]
-
-
-class TestCandidateObservation:
- """Tests for candidate observation model.
-
- Candidates are generated in the SEED phase and validated
- before becoming full observations.
- """
-
- def test_create_candidate(self):
- """Should create candidate with content and seed memories."""
- candidate = CandidateObservation(
- content="User prefers async communication over meetings",
- seed_memory_ids=["mem-slack-pref", "mem-meeting-decline"],
- )
-
- assert candidate.content == "User prefers async communication over meetings"
- assert len(candidate.seed_memory_ids) == 2
- assert "mem-slack-pref" in candidate.seed_memory_ids
diff --git a/hindsight-api/tests/test_reflect_agent.py b/hindsight-api/tests/test_reflect_agent.py
index 132022f8..5ebd79ef 100644
--- a/hindsight-api/tests/test_reflect_agent.py
+++ b/hindsight-api/tests/test_reflect_agent.py
@@ -1,1150 +1,284 @@
-"""Tests for the reflect agent and its tools."""
+"""
+Tests for the reflect agent with mocked LLM outputs.
-import uuid
-from unittest.mock import AsyncMock, MagicMock
+These tests verify:
+1. Tool name normalization for various LLM output formats
+2. Recovery from unknown tool calls
+3. Recovery from tool execution errors
+"""
import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
-from hindsight_api.engine.reflect.agent import run_reflect_agent
-from hindsight_api.engine.reflect.models import (
- AnswerSection,
- MentalModelInput,
- MentalModelObservation,
- ReflectAction,
- ReflectActionBatch,
- ReflectAgentResult,
-)
-from hindsight_api.engine.reflect.tools import (
- generate_model_id,
- tool_expand,
- tool_learn,
- tool_lookup,
- tool_recall,
+from hindsight_api.engine.reflect.agent import (
+ _normalize_tool_name,
+ _is_done_tool,
+ run_reflect_agent,
)
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult
-class TestGenerateModelId:
- """Test model ID generation."""
-
- def test_basic_name(self):
- """Test simple name conversion."""
- assert generate_model_id("My Model") == "my-model"
-
- def test_special_characters(self):
- """Test name with special characters."""
- assert generate_model_id("Alice's Project (2024)") == "alice-s-project-2024"
-
- def test_truncation(self):
- """Test long name truncation."""
- long_name = "A" * 100
- result = generate_model_id(long_name)
- assert len(result) <= 50
-
- def test_leading_trailing_hyphens(self):
- """Test that leading/trailing hyphens are stripped."""
- assert generate_model_id("--Test--") == "test"
-
-
-class TestToolLookup:
- """Test the lookup tool."""
-
- @pytest.fixture
- def mock_conn(self):
- """Create a mock database connection."""
- conn = AsyncMock()
- return conn
-
- async def test_list_all_models(self, mock_conn):
- """Test listing all mental models (compact: id, name, description only)."""
- mock_conn.fetch.return_value = [
- {
- "id": "model-1",
- "subtype": "learned",
- "name": "Model 1",
- "description": "First model",
- },
- {
- "id": "model-2",
- "subtype": "structural",
- "name": "Model 2",
- "description": "Second model",
- },
- ]
-
- result = await tool_lookup(mock_conn, "test-bank")
-
- assert result["count"] == 2
- assert len(result["models"]) == 2
- assert result["models"][0]["id"] == "model-1"
- assert result["models"][0]["name"] == "Model 1"
- assert "observation_titles" not in result["models"][0] # No observation_titles in list view
- assert result["models"][1]["id"] == "model-2"
-
- async def test_get_specific_model(self, mock_conn):
- """Test getting a specific mental model."""
- mock_conn.fetchrow.return_value = {
- "id": "model-1",
- "subtype": "learned",
- "name": "Model 1",
- "description": "First model",
- "observations": {
- "observations": [
- {
- "title": "Overview",
- "content": "Full summary of model 1",
- "evidence": [
- {"memory_id": "mem-1", "quote": "quote 1", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"},
- {"memory_id": "mem-2", "quote": "quote 2", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"},
- ],
- "created_at": "2024-01-01T00:00:00Z",
- }
- ]
- },
- "entity_id": None,
- "last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"),
- }
-
- result = await tool_lookup(mock_conn, "test-bank", "model-1")
-
- assert result["found"] is True
- assert result["model"]["id"] == "model-1"
- assert len(result["model"]["observations"]) == 1
- # Observations are now Observation objects
- obs = result["model"]["observations"][0]
- assert obs.content == "Full summary of model 1"
- assert obs.title == "Overview"
- assert len(obs.evidence) == 2
- assert obs.evidence[0].memory_id == "mem-1"
-
- async def test_model_not_found(self, mock_conn):
- """Test looking up non-existent model."""
- mock_conn.fetchrow.return_value = None
-
- result = await tool_lookup(mock_conn, "test-bank", "non-existent")
-
- assert result["found"] is False
- assert result["model_id"] == "non-existent"
-
-
-class TestToolLearn:
- """Test the learn tool.
-
- The learn tool creates placeholder mental models with name/description only.
- Actual content is generated in the background via refresh.
- """
-
- @pytest.fixture
- def mock_conn(self):
- """Create a mock database connection."""
- conn = AsyncMock()
- return conn
-
- async def test_create_new_model(self, mock_conn):
- """Test creating a new mental model placeholder."""
- mock_conn.fetchrow.return_value = None # Model doesn't exist
-
- input_model = MentalModelInput(
- name="Test Model",
- description="A test model to track important patterns",
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "created"
- assert result["model_id"] == "test-model"
- assert result["name"] == "Test Model"
- assert result["pending_generation"] is True
- mock_conn.execute.assert_called_once()
-
- async def test_update_existing_model(self, mock_conn):
- """Test updating an existing mental model."""
- mock_conn.fetchrow.return_value = {"id": "test-model"} # Model exists
-
- input_model = MentalModelInput(
- name="Test Model",
- description="Updated description",
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "updated"
- assert result["model_id"] == "test-model"
-
- async def test_learn_with_entity_id(self, mock_conn):
- """Test creating model linked to an entity."""
- mock_conn.fetchrow.return_value = None
-
- entity_uuid = str(uuid.uuid4())
- input_model = MentalModelInput(
- name="Entity Model",
- description="Model linked to entity",
- entity_id=entity_uuid,
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "created"
- assert result["pending_generation"] is True
- # Verify entity_uuid was passed to the execute call
- call_args = mock_conn.execute.call_args
- assert uuid.UUID(entity_uuid) in call_args[0]
-
- async def test_learn_creates_empty_observations(self, mock_conn):
- """Test that learn creates model with empty observations (content generated later)."""
- mock_conn.fetchrow.return_value = None # Model doesn't exist
-
- input_model = MentalModelInput(
- name="Model With Sources",
- description="A model to track source facts",
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "created"
- assert result["pending_generation"] is True
- # Verify the INSERT query was called with empty observations
- call_args = mock_conn.execute.call_args
- # observations should be empty JSON
- assert "'{}'::jsonb" in call_args[0][0]
-
-
-class TestToolExpand:
- """Test the expand tool."""
-
- @pytest.fixture
- def mock_conn(self):
- """Create a mock database connection."""
- conn = AsyncMock()
- return conn
-
- async def test_empty_memory_ids(self, mock_conn):
- """Test expand with empty memory_ids list."""
- result = await tool_expand(mock_conn, "test-bank", [], "chunk")
-
- assert "error" in result
- assert "memory_ids is required" in result["error"]
-
- async def test_invalid_memory_id(self, mock_conn):
- """Test expand with invalid UUID format."""
- result = await tool_expand(mock_conn, "test-bank", ["not-a-uuid"], "chunk")
-
- assert "error" in result
- assert "No valid memory IDs provided" in result["error"]
-
- async def test_memory_not_found(self, mock_conn):
- """Test expand with non-existent memory."""
- mock_conn.fetch.return_value = [] # No memories found
- memory_id = str(uuid.uuid4())
-
- result = await tool_expand(mock_conn, "test-bank", [memory_id], "chunk")
-
- assert "results" in result
- assert len(result["results"]) == 1
- assert "error" in result["results"][0]
- assert "Memory not found" in result["results"][0]["error"]
-
- async def test_expand_to_chunk(self, mock_conn):
- """Test expanding memory to chunk level."""
- memory_id = uuid.uuid4()
- # Mock batch fetch for memories
- mock_conn.fetch.side_effect = [
- # First call: get memories
- [
- {
- "id": memory_id,
- "text": "Memory text",
- "chunk_id": "chunk-1",
- "document_id": "doc-1",
- "fact_type": "experience",
- "context": "some context",
- }
- ],
- # Second call: get chunks
- [
- {
- "chunk_id": "chunk-1",
- "chunk_text": "Full chunk text with more context",
- "chunk_index": 0,
- "document_id": "doc-1",
- }
- ],
- ]
-
- result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "chunk")
-
- assert "results" in result
- assert len(result["results"]) == 1
- assert result["results"][0]["memory"]["text"] == "Memory text"
- assert result["results"][0]["chunk"]["text"] == "Full chunk text with more context"
- assert "document" not in result["results"][0] # depth=chunk doesn't include document
-
- async def test_expand_to_document(self, mock_conn):
- """Test expanding memory to document level."""
- memory_id = uuid.uuid4()
- mock_conn.fetch.side_effect = [
- # First call: get memories
- [
- {
- "id": memory_id,
- "text": "Memory text",
- "chunk_id": "chunk-1",
- "document_id": "doc-1",
- "fact_type": "experience",
- "context": None,
- }
- ],
- # Second call: get chunks
- [
- {
- "chunk_id": "chunk-1",
- "chunk_text": "Chunk text",
- "chunk_index": 0,
- "document_id": "doc-1",
- }
- ],
- # Third call: get documents
- [
- {
- "id": "doc-1",
- "original_text": "Full document text here",
- "metadata": {"source": "test"},
- "retain_params": {},
- }
- ],
- ]
-
- result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "document")
-
- assert "results" in result
- assert len(result["results"]) == 1
- assert "memory" in result["results"][0]
- assert "chunk" in result["results"][0]
- assert "document" in result["results"][0]
- assert result["results"][0]["document"]["full_text"] == "Full document text here"
-
- async def test_expand_multiple_memories(self, mock_conn):
- """Test expanding multiple memories in a single batch."""
- memory_id_1 = uuid.uuid4()
- memory_id_2 = uuid.uuid4()
- mock_conn.fetch.side_effect = [
- # First call: get memories
- [
- {
- "id": memory_id_1,
- "text": "Memory 1",
- "chunk_id": "chunk-1",
- "document_id": "doc-1",
- "fact_type": "experience",
- "context": None,
- },
- {
- "id": memory_id_2,
- "text": "Memory 2",
- "chunk_id": "chunk-2",
- "document_id": "doc-1",
- "fact_type": "world",
- "context": None,
- },
- ],
- # Second call: get chunks
- [
- {
- "chunk_id": "chunk-1",
- "chunk_text": "Chunk 1 text",
- "chunk_index": 0,
- "document_id": "doc-1",
- },
- {
- "chunk_id": "chunk-2",
- "chunk_text": "Chunk 2 text",
- "chunk_index": 1,
- "document_id": "doc-1",
- },
- ],
- ]
-
- result = await tool_expand(mock_conn, "test-bank", [str(memory_id_1), str(memory_id_2)], "chunk")
-
- assert "results" in result
- assert result["count"] == 2
- assert result["results"][0]["memory"]["text"] == "Memory 1"
- assert result["results"][1]["memory"]["text"] == "Memory 2"
-
-
-class TestToolRecall:
- """Test the recall tool."""
-
- async def test_recall_returns_memories(self):
- """Test recall searches and returns memories."""
- mock_engine = AsyncMock()
- mock_result = MagicMock()
- mock_result.results = [
- MagicMock(
- id=uuid.uuid4(),
- text="Memory 1",
- fact_type="experience",
- entities=["Alice"],
- occurred_start="2024-01-01",
- ),
- MagicMock(
- id=uuid.uuid4(),
- text="Memory 2",
- fact_type="world",
- entities=None,
- occurred_start=None,
- ),
- ]
- mock_engine.recall_async.return_value = mock_result
-
- mock_request_context = MagicMock()
-
- result = await tool_recall(mock_engine, "test-bank", "test query", mock_request_context)
-
- assert result["query"] == "test query"
- assert result["count"] == 2
- assert len(result["memories"]) == 2
- assert result["memories"][0]["text"] == "Memory 1"
- assert result["memories"][0]["entities"] == ["Alice"]
-
- # Verify recall_async was called with correct params
- mock_engine.recall_async.assert_called_once()
- call_kwargs = mock_engine.recall_async.call_args[1]
- assert call_kwargs["bank_id"] == "test-bank"
- assert call_kwargs["query"] == "test query"
- assert call_kwargs["fact_type"] == ["experience", "world"] # No opinions
-
-
-class TestPromptSize:
- """Test that prompts stay within reasonable size limits.
-
- Large prompts cause slow LLM responses (120s+ observed in production).
- The agent should not pre-load all mental models; use lookup() instead.
- """
-
- def test_initial_prompt_is_small(self):
- """Verify the initial prompt (no tool history) is reasonably small."""
- from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools
-
- # Typical bank profile
- bank_profile = {
- "name": "Test Assistant",
- "mission": "A helpful assistant for tracking engineering team activities. Help the team stay organized and informed.",
- }
-
- # First iteration: no context history
- context_history: list[dict] = []
- query = "Who should take ownership of storing load test scripts in Git?"
-
- # No additional context (mental models not pre-loaded)
- prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None)
- system_prompt = build_system_prompt_for_tools(bank_profile)
-
- total_chars = len(prompt) + len(system_prompt)
- estimated_tokens = total_chars // 4 # Rough estimate
-
- # Initial prompt should be under 3000 tokens (~12k chars)
- # This ensures fast LLM responses on the first iteration
- assert total_chars < 12000, f"Initial prompt too large: {total_chars} chars (~{estimated_tokens} tokens)"
- assert estimated_tokens < 3000, f"Initial prompt too large: ~{estimated_tokens} tokens"
-
- def test_prompt_with_tool_history_grows_reasonably(self):
- """Verify prompts grow reasonably with tool results."""
- from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools
-
- bank_profile = {
- "name": "Test Assistant",
- "mission": "A helpful assistant. Help the team.",
- }
-
- # Simulate recall result with 50 memories (realistic scenario)
- recall_result = {
- "query": "test query",
- "count": 50,
- "memories": [
- {"id": f"mem-{i}", "text": f"This is memory number {i} with some content.", "type": "experience"}
- for i in range(50)
- ],
- }
-
- context_history = [{"tool": "recall", "input": {"query": "test"}, "output": recall_result}]
- query = "What do you know about the team?"
-
- prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None)
- system_prompt = build_system_prompt_for_tools(bank_profile)
-
- total_chars = len(prompt) + len(system_prompt)
- estimated_tokens = total_chars // 4
-
- # With tool results, prompt should still be manageable (<20k tokens)
- assert total_chars < 80000, f"Prompt with tools too large: {total_chars} chars (~{estimated_tokens} tokens)"
-
-
-class TestReflectAgent:
- """Test the reflect agent loop with native tool calling."""
+class TestToolNameNormalization:
+ """Test tool name normalization for various LLM output formats."""
+
+ def test_normalize_standard_name(self):
+ """Standard tool names should pass through unchanged."""
+ assert _normalize_tool_name("done") == "done"
+ assert _normalize_tool_name("recall") == "recall"
+ assert _normalize_tool_name("search_reflections") == "search_reflections"
+ assert _normalize_tool_name("search_mental_models") == "search_mental_models"
+ assert _normalize_tool_name("expand") == "expand"
+
+ def test_normalize_functions_prefix(self):
+ """Tool names with 'functions.' prefix should be normalized."""
+ assert _normalize_tool_name("functions.done") == "done"
+ assert _normalize_tool_name("functions.recall") == "recall"
+ assert _normalize_tool_name("functions.search_reflections") == "search_reflections"
+
+ def test_normalize_call_equals_prefix(self):
+ """Tool names with 'call=' prefix should be normalized."""
+ assert _normalize_tool_name("call=done") == "done"
+ assert _normalize_tool_name("call=recall") == "recall"
+
+ def test_normalize_call_equals_functions_prefix(self):
+ """Tool names with 'call=functions.' prefix should be normalized."""
+ assert _normalize_tool_name("call=functions.done") == "done"
+ assert _normalize_tool_name("call=functions.recall") == "recall"
+ assert _normalize_tool_name("call=functions.search_mental_models") == "search_mental_models"
+
+ def test_is_done_tool(self):
+ """Test _is_done_tool helper."""
+ # Standard
+ assert _is_done_tool("done") is True
+ assert _is_done_tool("recall") is False
+
+ # With prefixes
+ assert _is_done_tool("functions.done") is True
+ assert _is_done_tool("call=done") is True
+ assert _is_done_tool("call=functions.done") is True
+
+ # Not done
+ assert _is_done_tool("functions.recall") is False
+ assert _is_done_tool("call=functions.recall") is False
+
+
+class TestReflectAgentMocked:
+ """Test reflect agent with mocked LLM outputs."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM provider."""
- llm = AsyncMock()
+ llm = MagicMock()
+ llm.call_with_tools = AsyncMock()
+ # Also mock call() for final iteration fallback
+ llm.call = AsyncMock(return_value="Fallback answer from final iteration")
return llm
@pytest.fixture
- def bank_profile(self):
- """Create a test bank profile."""
+ def mock_functions(self):
+ """Create mock search/recall functions."""
return {
- "name": "Test Assistant",
- "mission": "A helpful test assistant. Help with testing.",
+ "search_reflections_fn": AsyncMock(return_value={"reflections": []}),
+ "search_mental_models_fn": AsyncMock(return_value={"mental_models": []}),
+ "recall_fn": AsyncMock(return_value={"memories": [{"id": "mem-1", "content": "test memory"}]}),
+ "expand_fn": AsyncMock(return_value={"memories": []}),
}
- @pytest.fixture
- def mock_tools(self):
- """Create mock tool callbacks."""
- # Include memory IDs in recall results so guardrail passes
- memory_id = str(uuid.uuid4())
- return {
- "lookup_fn": AsyncMock(return_value={"count": 0, "models": []}),
- "recall_fn": AsyncMock(return_value={
- "query": "test",
- "count": 1,
- "memories": [{"id": memory_id, "text": "Memory", "type": "experience"}]
- }),
- "learn_fn": AsyncMock(return_value={"status": "created", "model_id": "new-model"}),
- "expand_fn": AsyncMock(return_value={
- "results": [{"memory_id": "123", "memory": {"id": "123", "text": "Memory text"}}],
- "count": 1
- }),
- }
+ @pytest.mark.asyncio
+ async def test_handles_functions_prefix_in_done(self, mock_llm, mock_functions):
+ """Test that 'functions.done' is handled correctly."""
+ # First call: LLM calls recall
+ # Second call: LLM calls functions.done
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="2",
+ name="functions.done",
+ arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
- def _make_tool_result(self, tool_calls: list[dict]) -> LLMToolCallResult:
- """Helper to create LLMToolCallResult from tool call dicts."""
- return LLMToolCallResult(
- tool_calls=[
- LLMToolCall(id=f"call_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
- for i, tc in enumerate(tool_calls)
- ],
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Test answer"
+ assert "mem-1" in result.used_memory_ids
+
+ @pytest.mark.asyncio
+ async def test_handles_call_equals_functions_prefix(self, mock_llm, mock_functions):
+ """Test that 'call=functions.done' is handled correctly."""
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="2",
+ name="call=functions.done",
+ arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Test answer"
+
+ @pytest.mark.asyncio
+ async def test_recovery_from_unknown_tool(self, mock_llm, mock_functions):
+ """Test that LLM can recover after calling an unknown tool."""
+ # First call: LLM calls unknown tool
+ # Second call: LLM calls valid recall after seeing error
+ # Third call: LLM calls done
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="invalid_tool", arguments={"foo": "bar"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="3",
+ name="done",
+ arguments={"answer": "Recovered successfully", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Recovered successfully"
+ # Verify the LLM was called 3 times (initial + recovery + done)
+ assert mock_llm.call_with_tools.call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_recovery_from_tool_execution_error(self, mock_llm, mock_functions):
+ """Test that LLM can recover after a tool execution fails."""
+ # Make recall fail the first time, succeed the second time
+ mock_functions["recall_fn"].side_effect = [
+ Exception("Database connection failed"),
+ {"memories": [{"id": "mem-1", "content": "test memory"}]},
+ ]
+
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ # LLM tries again after seeing error
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test retry"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="3",
+ name="done",
+ arguments={"answer": "Recovered from error", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Recovered from error"
+ assert mock_llm.call_with_tools.call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_normalizes_tool_names_in_other_tools(self, mock_llm, mock_functions):
+ """Test that tool names are normalized for all tools, not just done."""
+ mock_llm.call_with_tools.side_effect = [
+ # LLM calls 'functions.recall' instead of 'recall'
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="functions.recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="2",
+ name="done",
+ arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Test answer"
+ # Verify recall was actually called (normalization worked)
+ mock_functions["recall_fn"].assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_max_iterations_reached(self, mock_llm, mock_functions):
+ """Test that agent stops after max iterations even with errors."""
+ # LLM keeps calling unknown tools
+ mock_llm.call_with_tools.return_value = LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="unknown_tool", arguments={})],
finish_reason="tool_calls",
)
- async def test_agent_done_immediately_rejected_by_guardrail(self, mock_llm, bank_profile, mock_tools):
- """Test that guardrail rejects done without evidence gathering."""
- # First call: agent tries to return done immediately (rejected by guardrail)
- # Second call: agent gathers evidence
- # Third call: agent returns done with evidence
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]),
- # After guardrail rejection, agent should gather evidence
- self._make_tool_result([{"name": "recall", "arguments": {"query": "test query"}}]),
- # Now with evidence, done is accepted
- self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]),
- ]
-
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
- query="What is the answer?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert isinstance(result, ReflectAgentResult)
- assert result.text == "The answer is 42."
- # 3 iterations: rejected done, recall, accepted done
- assert result.iterations == 3
- # Tools called: list_mental_models (auto at start of each iteration) + recall
- # The exact count may vary based on implementation
- assert result.tools_called >= 1 # At least recall was called
-
- async def test_agent_calls_tools_then_done(self, mock_llm, bank_profile, mock_tools):
- """Test agent that calls tools before completing."""
- # First call: lookup and recall
- # Second call: done
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([
- {"name": "list_mental_models", "arguments": {}},
- {"name": "recall", "arguments": {"query": "test query"}},
- ]),
- self._make_tool_result([
- {"name": "done", "arguments": {"answer": "Based on my research, the answer is yes."}},
- ]),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Is testing important?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert result.text == "Based on my research, the answer is yes."
- assert result.iterations == 2
- # Tools called: list_mental_models + recall (+ possibly auto list_mental_models)
- assert result.tools_called >= 2
- mock_tools["recall_fn"].assert_called_once_with("test query", 2048)
-
- async def test_agent_learns_model(self, mock_llm, bank_profile, mock_tools):
- """Test agent that creates a mental model placeholder."""
- mock_tools["learn_fn"].return_value = {"status": "created", "model_id": "new-insight", "pending_generation": True}
-
- mock_llm.call_with_tools.side_effect = [
- # First: gather evidence via recall (required by guardrail)
- self._make_tool_result([{"name": "recall", "arguments": {"query": "user preferences"}}]),
- # Then: learn from the gathered evidence
- self._make_tool_result([{
- "name": "learn",
- "arguments": {
- "name": "New Insight",
- "description": "Track patterns about user preferences and communication style",
- }
- }]),
- # Finally: done with the learning
- self._make_tool_result([{"name": "done", "arguments": {"answer": "I've learned something new."}}]),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What can you learn?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert result.mental_models_created == ["new-insight"]
- mock_tools["learn_fn"].assert_called_once()
- # Verify the learn_fn was called with name and description only
- call_args = mock_tools["learn_fn"].call_args
- mental_model_arg = call_args[0][0]
- assert mental_model_arg.name == "New Insight"
- assert "preferences" in mental_model_arg.description
-
- async def test_agent_max_iterations_forces_response(self, mock_llm, bank_profile, mock_tools):
- """Test that max iterations forces a text response."""
- # Return tools indefinitely, then final plain text call
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]),
- self._make_tool_result([{"name": "recall", "arguments": {"query": "query2"}}]),
- ]
- # On last iteration, LLM.call is used (not call_with_tools)
- mock_llm.call.return_value = "Forced final answer after max iterations."
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Test question",
- bank_profile=bank_profile,
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
max_iterations=3,
- **mock_tools,
+ **mock_functions,
)
- assert result.text == "Forced final answer after max iterations."
+ # Should have a result even if no memories found
+ assert result is not None
assert result.iterations == 3
-
- async def test_agent_handles_tool_error(self, mock_llm, bank_profile, mock_tools):
- """Test agent propagates tool execution errors."""
- # Make recall fail
- mock_tools["recall_fn"].side_effect = Exception("Database error")
-
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]),
- ]
-
- # Tool errors are now propagated as RuntimeError
- with pytest.raises(RuntimeError) as exc_info:
- await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Test question",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert "Database error" in str(exc_info.value)
-
- async def test_agent_parallel_tool_calls(self, mock_llm, bank_profile, mock_tools):
- """Test agent executes multiple tools in parallel."""
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([
- {"name": "list_mental_models", "arguments": {}},
- {"name": "recall", "arguments": {"query": "query1"}},
- {"name": "recall", "arguments": {"query": "query2"}},
- ]),
- self._make_tool_result([{"name": "done", "arguments": {"answer": "Done after parallel calls."}}]),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Test question",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- # Tools called: list_mental_models + 2x recall (+ possibly auto list_mental_models)
- assert result.tools_called >= 3
- # recall should be called twice
- assert mock_tools["recall_fn"].call_count == 2
-
- async def test_agent_returns_validated_memory_ids(self, mock_llm, bank_profile):
- """Test agent returns only validated memory IDs that were actually recalled."""
- memory_id_1 = str(uuid.uuid4())
- memory_id_2 = str(uuid.uuid4())
-
- # Mock recall returns these specific memory IDs
- mock_recall = AsyncMock(
- return_value={
- "query": "test",
- "count": 2,
- "memories": [
- {"id": memory_id_1, "text": "Memory 1", "type": "experience"},
- {"id": memory_id_2, "text": "Memory 2", "type": "world"},
- ],
- }
- )
-
- mock_llm.call_with_tools.side_effect = [
- LLMToolCallResult(
- tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})],
- finish_reason="tool_calls",
- ),
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_1",
- name="done",
- arguments={"answer": "Based on the evidence...", "memory_ids": [memory_id_1, memory_id_2]}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What do we know?",
- bank_profile=bank_profile,
- lookup_fn=AsyncMock(return_value={"count": 0, "models": []}),
- recall_fn=mock_recall,
- expand_fn=AsyncMock(return_value={}),
- )
-
- # Both memory IDs should be in the result (they were recalled)
- assert memory_id_1 in result.used_memory_ids
- assert memory_id_2 in result.used_memory_ids
- assert len(result.used_memory_ids) == 2
-
- async def test_agent_filters_hallucinated_memory_ids(self, mock_llm, bank_profile):
- """Test agent filters out memory IDs that were not in recall results."""
- valid_memory_id = str(uuid.uuid4())
- hallucinated_memory_id = str(uuid.uuid4())
-
- # Mock recall returns only one memory ID
- mock_recall = AsyncMock(
- return_value={
- "query": "test",
- "count": 1,
- "memories": [
- {"id": valid_memory_id, "text": "Real memory", "type": "experience"},
- ],
- }
- )
-
- mock_llm.call_with_tools.side_effect = [
- LLMToolCallResult(
- tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})],
- finish_reason="tool_calls",
- ),
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_1",
- name="done",
- arguments={"answer": "Based on evidence...", "memory_ids": [valid_memory_id, hallucinated_memory_id]}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What do we know?",
- bank_profile=bank_profile,
- lookup_fn=AsyncMock(return_value={"count": 0, "models": []}),
- recall_fn=mock_recall,
- expand_fn=AsyncMock(return_value={}),
- )
-
- # Only the valid memory ID should be in the result
- assert valid_memory_id in result.used_memory_ids
- assert hallucinated_memory_id not in result.used_memory_ids
- assert len(result.used_memory_ids) == 1
-
- async def test_agent_returns_validated_model_ids(self, mock_llm, bank_profile):
- """Test agent returns only validated model IDs that were actually looked up."""
- model_id = "team-structure"
- hallucinated_model_id = "non-existent-model"
-
- # Mock lookup returns different results based on input
- # - None (or no arg): list_mental_models - returns list of models
- # - model_id: get_mental_model - returns specific model with found=True
- async def mock_lookup_impl(arg=None):
- if arg is None:
- return {"count": 1, "models": [{"id": model_id, "name": "Team Structure", "description": "desc"}]}
- else:
- return {"found": True, "model": {"id": model_id, "name": "Team Structure", "summary": "Full summary"}}
-
- mock_lookup = AsyncMock(side_effect=mock_lookup_impl)
-
- mock_llm.call_with_tools.side_effect = [
- LLMToolCallResult(
- tool_calls=[
- LLMToolCall(id="call_0", name="list_mental_models", arguments={}),
- LLMToolCall(id="call_1", name="get_mental_model", arguments={"model_id": model_id}),
- ],
- finish_reason="tool_calls",
- ),
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_2",
- name="done",
- arguments={"answer": "Based on team structure...", "model_ids": [model_id, hallucinated_model_id]}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="How is the team organized?",
- bank_profile=bank_profile,
- lookup_fn=mock_lookup,
- recall_fn=AsyncMock(return_value={"query": "test", "count": 0, "memories": []}),
- expand_fn=AsyncMock(return_value={}),
- )
-
- # Only the valid model ID should be in the result
- assert model_id in result.used_model_ids
- assert hallucinated_model_id not in result.used_model_ids
-
- async def test_agent_plain_text_answer(self, mock_llm, bank_profile, mock_tools):
- """Test agent with plain text answer format."""
- mock_llm.call_with_tools.side_effect = [
- # First: gather evidence via recall (required by guardrail)
- LLMToolCallResult(
- tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "answer"})],
- finish_reason="tool_calls",
- ),
- # Then: done with plain text answer
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_1",
- name="done",
- arguments={"answer": "The answer is simple and direct."}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What's the answer?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert result.text == "The answer is simple and direct."
-
- async def test_agent_includes_directives_in_system_prompt(self, mock_llm, bank_profile, mock_tools):
- """Test that directives are included in the system prompt."""
- from hindsight_api.engine.reflect.observations import Observation
-
- # Create directive with Observation objects (new format)
- directives = [
- {
- "id": "response-rules",
- "name": "Response Rules",
- "description": "Rules for responses",
- "subtype": "directive",
- "observations": [
- Observation(
- title="No Speculation",
- content="Never speculate about information not in the memories.",
- evidence=[],
- ),
- Observation(
- title="Be Concise",
- content="Always keep responses under 100 words.",
- evidence=[],
- ),
- ],
- },
- ]
-
- # Capture the system prompt
- captured_messages = []
-
- async def capture_call(*args, **kwargs):
- if "messages" in kwargs:
- captured_messages.extend(kwargs["messages"])
- return self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}])
-
- mock_llm.call_with_tools.side_effect = [
- # First: gather evidence (guardrail requirement)
- self._make_tool_result([{"name": "recall", "arguments": {"query": "test"}}]),
- # Then: done
- self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]),
- ]
-
- # Store original to check messages
- original_call = mock_llm.call_with_tools
-
- async def wrapped_call(*args, **kwargs):
- if "messages" in kwargs:
- captured_messages.extend(kwargs["messages"])
- return await original_call(*args, **kwargs)
-
- mock_llm.call_with_tools = wrapped_call
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What do we know?",
- bank_profile=bank_profile,
- directives=directives,
- **mock_tools,
- )
-
- # Find the system message
- system_messages = [m for m in captured_messages if m.get("role") == "system"]
- assert len(system_messages) > 0, "No system message found"
-
- system_content = system_messages[0]["content"]
-
- # Verify directives are in the system prompt
- assert "DIRECTIVES" in system_content, "Directives section not found in system prompt"
- assert "No Speculation" in system_content, "Directive title not found"
- assert "Never speculate" in system_content, "Directive content not found"
- assert "Be Concise" in system_content, "Second directive title not found"
- assert "100 words" in system_content, "Second directive content not found"
- assert "NEVER violate these directives" in system_content, "Directive warning not found"
-
-
-class TestDirectivesSection:
- """Test the directives section builder."""
-
- def test_build_directives_section_with_observation_objects(self):
- """Test building directives section with Observation objects."""
- from hindsight_api.engine.reflect.observations import Observation
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Safety Rules",
- "observations": [
- Observation(
- title="No Harmful Content",
- content="Never generate harmful or dangerous content.",
- evidence=[],
- ),
- ],
- },
- ]
-
- result = build_directives_section(directives)
-
- assert "DIRECTIVES" in result
- assert "No Harmful Content" in result
- assert "Never generate harmful" in result
- assert "NEVER violate" in result
-
- def test_build_directives_section_with_dicts(self):
- """Test building directives section with dict observations."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Safety Rules",
- "observations": [
- {
- "title": "No Harmful Content",
- "content": "Never generate harmful or dangerous content.",
- },
- ],
- },
- ]
-
- result = build_directives_section(directives)
-
- assert "DIRECTIVES" in result
- assert "No Harmful Content" in result
- assert "Never generate harmful" in result
-
- def test_build_directives_section_fallback_to_description(self):
- """Test that directives without observations use description."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Simple Rule",
- "description": "This is a simple rule to follow.",
- "observations": [],
- },
- ]
-
- result = build_directives_section(directives)
-
- assert "Simple Rule" in result
- assert "simple rule to follow" in result
-
- def test_build_directives_section_empty(self):
- """Test that empty directives returns empty string."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- result = build_directives_section([])
- assert result == ""
-
- result = build_directives_section(None)
- assert result == ""
-
-
-@pytest.mark.integration
-class TestReflectIntegration:
- """Integration tests for reflect with real database.
-
- These tests require a running database and LLM provider.
- Skip with: pytest -m "not integration"
- """
-
- async def test_reflect_creates_learned_mental_model(self, memory, request_context):
- """Test that reflect can create a 'learned' mental model via the agent."""
- bank_id = f"test-reflect-{uuid.uuid4().hex[:8]}"
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the team lead and manages the engineering team."},
- {"content": "The team has weekly planning meetings on Monday."},
- {"content": "Alice prefers asynchronous communication via Slack."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Run reflect - this should use the agentic loop
- result = await memory.reflect_async(
- bank_id=bank_id,
- query="What do you know about Alice and how she manages the team?",
- request_context=request_context,
- )
-
- assert result.text is not None
- assert len(result.text) > 0
-
- # Check if any mental models were created (may or may not happen depending on LLM)
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # If models were created, they should be 'learned' subtype
- for model in models:
- if model.get("subtype") == "learned":
- # Learned models are created as placeholders pending generation
- assert model.get("name") is not None
- assert model.get("description") is not None
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_reflect_learn_triggers_background_generation(self, memory, request_context):
- """Test that when reflect calls learn, background generation is triggered.
-
- This test verifies the full flow:
- 1. Agent decides to learn something important
- 2. learn tool creates a placeholder model
- 3. Background generation is automatically triggered
- """
- import asyncio
-
- bank_id = f"test-reflect-learn-{uuid.uuid4().hex[:8]}"
-
- # Add rich test data that should prompt the agent to learn something
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Bob is the CEO and founder of the company."},
- {"content": "Bob started the company in 2015 after leaving Google."},
- {"content": "Bob holds weekly all-hands meetings every Friday at 3pm."},
- {"content": "Bob's management style is very hands-off and trusts his team."},
- {"content": "Bob prefers face-to-face communication over email."},
- {"content": "Bob has a strong focus on company culture and team building."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Run reflect with a query that should prompt learning
- result = await memory.reflect_async(
- bank_id=bank_id,
- query="Tell me everything about Bob's leadership style and how he runs the company. "
- "This is important information I'll need to reference frequently.",
- request_context=request_context,
- )
-
- assert result.text is not None
- assert len(result.text) > 0
-
- # Wait for any background tasks to complete
- await memory.wait_for_background_tasks()
- # Give a bit more time for async generation
- await asyncio.sleep(2)
-
- # Check if learned models were created
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- learned_models = [m for m in models if m.get("subtype") == "learned"]
-
- # If learned models were created, verify they have proper structure
- for model in learned_models:
- assert model.get("name") is not None
- assert model.get("description") is not None
- # After background generation, the model should have been updated
- # (observations may or may not be populated depending on timing)
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_reflect_excludes_opinions_from_recall(self, memory, request_context):
- """Test that reflect's recall tool doesn't return opinions."""
- bank_id = f"test-reflect-no-opinions-{uuid.uuid4().hex[:8]}"
-
- # Add test data (note: we can't directly add opinions since opinion
- # extraction was removed, but we can verify recall behavior)
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "The weather today is sunny and warm."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Run recall directly to verify it excludes opinions
- recall_result = await memory.recall_async(
- bank_id=bank_id,
- query="weather",
- request_context=request_context,
- )
-
- # All returned facts should be experience or world, not opinion
- for fact in recall_result.results:
- assert fact.fact_type in ["experience", "world"]
- assert fact.fact_type != "opinion"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
diff --git a/hindsight-api/tests/test_reflections.py b/hindsight-api/tests/test_reflections.py
new file mode 100644
index 00000000..25437e06
--- /dev/null
+++ b/hindsight-api/tests/test_reflections.py
@@ -0,0 +1,359 @@
+"""Tests for reflections, mental models, and learnings functionality."""
+
+import uuid
+
+import pytest
+import pytest_asyncio
+import httpx
+from hindsight_api.api import create_app
+from hindsight_api.engine.memory_engine import MemoryEngine
+
+
+@pytest_asyncio.fixture
+async def api_client(memory):
+ """Create an async test client for the FastAPI app."""
+ app = create_app(memory, initialize_memory=False)
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ yield client
+
+
+@pytest.fixture
+def test_bank_id():
+ """Provide a unique bank ID for this test run."""
+ return f"test_reflections_{uuid.uuid4().hex[:8]}"
+
+
+class TestReflectionsCRUD:
+ """Test reflections CRUD operations via memory engine."""
+
+ @pytest.mark.asyncio
+ async def test_create_and_get_reflection(self, memory: MemoryEngine, request_context):
+ """Test creating and retrieving a reflection."""
+ bank_id = f"test-reflection-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank first
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create a reflection
+ reflection = await memory.create_reflection(
+ bank_id=bank_id,
+ name="Team Preferences",
+ source_query="What are the team's communication preferences?",
+ content="The team prefers async communication via Slack",
+ tags=["team"],
+ request_context=request_context,
+ )
+
+ assert reflection["name"] == "Team Preferences"
+ assert reflection["source_query"] == "What are the team's communication preferences?"
+ assert reflection["content"] == "The team prefers async communication via Slack"
+ assert reflection["tags"] == ["team"]
+ assert "id" in reflection
+
+ # Get the reflection
+ fetched = await memory.get_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection["id"],
+ request_context=request_context,
+ )
+
+ assert fetched["id"] == reflection["id"]
+ assert fetched["name"] == "Team Preferences"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_list_reflections(self, memory: MemoryEngine, request_context):
+ """Test listing reflections with filters."""
+ bank_id = f"test-reflection-list-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank first
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create multiple reflections
+ await memory.create_reflection(
+ bank_id=bank_id,
+ name="Reflection 1",
+ source_query="Query 1",
+ content="Content 1",
+ tags=["tag1"],
+ request_context=request_context,
+ )
+ await memory.create_reflection(
+ bank_id=bank_id,
+ name="Reflection 2",
+ source_query="Query 2",
+ content="Content 2",
+ tags=["tag2"],
+ request_context=request_context,
+ )
+
+ # List all
+ all_reflections = await memory.list_reflections(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(all_reflections) == 2
+
+ # List with tag filter
+ tag1_reflections = await memory.list_reflections(
+ bank_id=bank_id,
+ tags=["tag1"],
+ request_context=request_context,
+ )
+ assert len(tag1_reflections) == 1
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_update_reflection(self, memory: MemoryEngine, request_context):
+ """Test updating a reflection."""
+ bank_id = f"test-reflection-update-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank first
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create a reflection
+ reflection = await memory.create_reflection(
+ bank_id=bank_id,
+ name="Original Name",
+ source_query="Original Query",
+ content="Original Content",
+ request_context=request_context,
+ )
+
+ # Update the reflection
+ updated = await memory.update_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection["id"],
+ name="Updated Name",
+ content="Updated Content",
+ request_context=request_context,
+ )
+
+ assert updated["name"] == "Updated Name"
+ assert updated["content"] == "Updated Content"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_delete_reflection(self, memory: MemoryEngine, request_context):
+ """Test deleting a reflection."""
+ bank_id = f"test-reflection-delete-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank first
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create a reflection
+ reflection = await memory.create_reflection(
+ bank_id=bank_id,
+ name="To Delete",
+ source_query="Query",
+ content="Content",
+ request_context=request_context,
+ )
+
+ # Delete the reflection
+ await memory.delete_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection["id"],
+ request_context=request_context,
+ )
+
+ # Verify deletion - should return None
+ fetched = await memory.get_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection["id"],
+ request_context=request_context,
+ )
+ assert fetched is None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestMentalModelsAPI:
+ """Test mental models API endpoints.
+
+ NOTE: Mental models are now stored in memory_units with fact_type='mental_model'
+ and accessed via recall with fact_type=["mental_model"]. The old /mental-models
+ endpoint was removed. These tests are skipped.
+ """
+
+ @pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']")
+ @pytest.mark.asyncio
+ async def test_list_mental_models_empty(self, api_client, test_bank_id):
+ """Test listing mental models when none exist."""
+ pass
+
+ @pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']")
+ @pytest.mark.asyncio
+ async def test_get_mental_model_not_found(self, api_client, test_bank_id):
+ """Test getting a non-existent mental model."""
+ pass
+
+
+class TestReflectionsAPI:
+ """Test reflections API endpoints."""
+
+ @pytest.mark.asyncio
+ async def test_reflections_api_crud(self, api_client, test_bank_id):
+ """Test full CRUD cycle through API."""
+ import asyncio
+
+ # Create bank first via profile endpoint
+ await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
+
+ # Create a reflection (async operation)
+ response = await api_client.post(
+ f"/v1/default/banks/{test_bank_id}/reflections",
+ json={
+ "name": "API Test Reflection",
+ "source_query": "What is the API test about?",
+ "content": "This is an API test reflection",
+ "tags": ["api-test"],
+ },
+ )
+ assert response.status_code == 200
+ create_result = response.json()
+ assert "operation_id" in create_result
+ operation_id = create_result["operation_id"]
+
+ # Wait for the async operation to complete
+ for _ in range(30): # Wait up to 30 seconds
+ response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{operation_id}")
+ if response.status_code == 200:
+ op_status = response.json()
+ if op_status.get("status") == "completed":
+ break
+ await asyncio.sleep(1)
+
+ # List reflections to get the created reflection
+ response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections")
+ assert response.status_code == 200
+ reflections = response.json()["items"]
+ assert len(reflections) >= 1
+
+ # Find our reflection
+ reflection = next((r for r in reflections if r["name"] == "API Test Reflection"), None)
+ assert reflection is not None, f"Reflection not found. Items: {reflections}"
+ reflection_id = reflection["id"]
+
+ # Get the reflection
+ response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
+ assert response.status_code == 200
+ assert response.json()["name"] == "API Test Reflection"
+
+ # Update the reflection
+ response = await api_client.patch(
+ f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}",
+ json={"name": "Updated API Test Reflection"},
+ )
+ assert response.status_code == 200
+ assert response.json()["name"] == "Updated API Test Reflection"
+
+ # Delete the reflection
+ response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
+ assert response.status_code == 200
+
+ # Verify deletion
+ response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
+ assert response.status_code == 404
+
+ # Cleanup
+ await api_client.delete(f"/v1/default/banks/{test_bank_id}")
+
+
+class TestRecallWithMentalModelsAndReflections:
+ """Test recall integration with mental models and reflections."""
+
+ @pytest.mark.asyncio
+ async def test_recall_includes_mental_models(self, api_client, test_bank_id):
+ """Test that recall can include mental models in the response."""
+ # Create bank first via profile endpoint
+ await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
+
+ # Note: Mental models are auto-created via consolidation, not manually
+ # This test just verifies the include parameter works
+
+ # Recall with mental models included
+ response = await api_client.post(
+ f"/v1/default/banks/{test_bank_id}/memories/recall",
+ json={
+ "query": "What is machine learning?",
+ "include": {
+ "mental_models": {"max_results": 5},
+ },
+ },
+ )
+ assert response.status_code == 200
+ result = response.json()
+
+ # Should have mental_models field in response (may be empty)
+ assert "mental_models" in result or result.get("mental_models") is None
+
+ # Cleanup
+ await api_client.delete(f"/v1/default/banks/{test_bank_id}")
+
+ @pytest.mark.asyncio
+ async def test_recall_includes_reflections(self, api_client, test_bank_id):
+ """Test that recall can include reflections in the response."""
+ # Create bank first via profile endpoint
+ await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
+
+ # Create a reflection first
+ response = await api_client.post(
+ f"/v1/default/banks/{test_bank_id}/reflections",
+ json={
+ "name": "AI Overview",
+ "source_query": "What is AI?",
+ "content": "Artificial intelligence is the simulation of human intelligence",
+ "tags": [],
+ },
+ )
+ assert response.status_code == 200
+
+ # Recall with reflections included
+ response = await api_client.post(
+ f"/v1/default/banks/{test_bank_id}/memories/recall",
+ json={
+ "query": "What is artificial intelligence?",
+ "include": {
+ "reflections": {"max_results": 5},
+ },
+ },
+ )
+ assert response.status_code == 200
+ result = response.json()
+
+ # Should have reflections in response (may be empty if embedding not generated yet)
+ assert "reflections" in result or result.get("reflections") is None
+
+ # Cleanup
+ await api_client.delete(f"/v1/default/banks/{test_bank_id}")
+
+ @pytest.mark.asyncio
+ async def test_recall_without_mental_models_by_default(self, api_client, test_bank_id):
+ """Test that recall does not include mental models by default."""
+ # Create bank first via profile endpoint
+ await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
+
+ # Recall without specifying mental models
+ response = await api_client.post(
+ f"/v1/default/banks/{test_bank_id}/memories/recall",
+ json={
+ "query": "Test query",
+ },
+ )
+ assert response.status_code == 200
+ result = response.json()
+
+ # Mental models should not be in response
+ assert result.get("mental_models") is None
+
+ # Cleanup
+ await api_client.delete(f"/v1/default/banks/{test_bank_id}")
diff --git a/hindsight-api/tests/test_server_module.py b/hindsight-api/tests/test_server_module.py
index 0d2d95a2..bc1d29bf 100644
--- a/hindsight-api/tests/test_server_module.py
+++ b/hindsight-api/tests/test_server_module.py
@@ -257,7 +257,6 @@ from hindsight_api.extensions import (
RetainContext,
RecallContext,
ReflectContext,
- RefreshMentalModelContext,
)
@@ -289,6 +288,3 @@ class MockOperationValidator(OperationValidatorExtension):
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
-
- async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
- return ValidationResult.accept()
diff --git a/hindsight-api/tests/test_sql_schema_safety.py b/hindsight-api/tests/test_sql_schema_safety.py
index b7ddd436..70f78681 100644
--- a/hindsight-api/tests/test_sql_schema_safety.py
+++ b/hindsight-api/tests/test_sql_schema_safety.py
@@ -21,6 +21,8 @@ TABLES = [
"documents",
"chunks",
"async_operations",
+ "directives",
+ "reflections",
]
# Files to scan for SQL queries
diff --git a/hindsight-api/tests/test_worker.py b/hindsight-api/tests/test_worker.py
index f9748678..7302c358 100644
--- a/hindsight-api/tests/test_worker.py
+++ b/hindsight-api/tests/test_worker.py
@@ -329,6 +329,241 @@ class TestWorkerPoller:
assert row["status"] == "failed"
assert "Max retries" in row["error_message"]
+ @pytest.mark.asyncio
+ async def test_claim_batch_skips_consolidation_when_same_bank_processing(self, pool, clean_operations):
+ """Test that pending consolidation is skipped if same bank has one processing."""
+ from hindsight_api.worker import WorkerPoller
+
+ bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
+
+ # Create a processing consolidation for bank
+ processing_op_id = uuid.uuid4()
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
+ VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'other-worker')
+ """,
+ processing_op_id,
+ bank_id,
+ json.dumps({"type": "consolidation", "bank_id": bank_id}),
+ )
+
+ # Create a pending consolidation for same bank (should be skipped)
+ pending_op_id = uuid.uuid4()
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
+ VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb)
+ """,
+ pending_op_id,
+ bank_id,
+ json.dumps({"type": "consolidation", "bank_id": bank_id}),
+ )
+
+ # Create a pending consolidation for different bank (should be claimed)
+ other_bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
+ other_op_id = uuid.uuid4()
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
+ VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb)
+ """,
+ other_op_id,
+ other_bank_id,
+ json.dumps({"type": "consolidation", "bank_id": other_bank_id}),
+ )
+
+ poller = WorkerPoller(
+ pool=pool,
+ worker_id="test-worker-1",
+ executor=lambda x: None,
+ batch_size=10,
+ )
+
+ claimed = await poller.claim_batch()
+
+ # Should only claim the consolidation for the other bank
+ assert len(claimed) == 1
+ claimed_op_id, claimed_payload = claimed[0]
+ assert claimed_op_id == str(other_op_id)
+ assert claimed_payload["bank_id"] == other_bank_id
+
+ # Verify the pending consolidation for first bank is still pending
+ row = await pool.fetchrow(
+ "SELECT status, worker_id FROM async_operations WHERE operation_id = $1",
+ pending_op_id,
+ )
+ assert row["status"] == "pending"
+ assert row["worker_id"] is None
+
+ @pytest.mark.asyncio
+ async def test_claim_batch_allows_non_consolidation_when_consolidation_processing(self, pool, clean_operations):
+ """Test that non-consolidation tasks are still claimed even if consolidation is processing."""
+ from hindsight_api.worker import WorkerPoller
+
+ bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
+
+ # Create a processing consolidation for bank
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
+ VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'other-worker')
+ """,
+ uuid.uuid4(),
+ bank_id,
+ json.dumps({"type": "consolidation", "bank_id": bank_id}),
+ )
+
+ # Create a pending retain task for same bank (should be claimed)
+ retain_op_id = uuid.uuid4()
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
+ VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
+ """,
+ retain_op_id,
+ bank_id,
+ json.dumps({"type": "batch_retain", "bank_id": bank_id}),
+ )
+
+ poller = WorkerPoller(
+ pool=pool,
+ worker_id="test-worker-1",
+ executor=lambda x: None,
+ batch_size=10,
+ )
+
+ claimed = await poller.claim_batch()
+
+ # Should claim the retain task (non-consolidation tasks are unaffected)
+ assert len(claimed) == 1
+ claimed_op_id, _ = claimed[0]
+ assert claimed_op_id == str(retain_op_id)
+
+
+class TestWorkerRecovery:
+ """Tests for worker task recovery on startup."""
+
+ @pytest.mark.asyncio
+ async def test_recover_own_tasks_resets_processing_to_pending(self, pool, clean_operations):
+ """Test that recover_own_tasks resets processing tasks back to pending."""
+ from hindsight_api.worker import WorkerPoller
+
+ # Create tasks that were being processed by this worker (simulating a crash)
+ bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
+ worker_id = "crashed-worker"
+ task_ids = []
+
+ for i in range(3):
+ op_id = uuid.uuid4()
+ task_ids.append(op_id)
+ payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, claimed_at)
+ VALUES ($1, $2, 'test', 'processing', $3::jsonb, $4, now())
+ """,
+ op_id,
+ bank_id,
+ payload,
+ worker_id,
+ )
+
+ # Create poller with same worker_id and call recover
+ poller = WorkerPoller(
+ pool=pool,
+ worker_id=worker_id,
+ executor=lambda x: None,
+ )
+
+ recovered_count = await poller.recover_own_tasks()
+ assert recovered_count == 3
+
+ # Verify all tasks are back to pending with no worker assigned
+ rows = await pool.fetch(
+ "SELECT status, worker_id, claimed_at FROM async_operations WHERE bank_id = $1",
+ bank_id,
+ )
+ for row in rows:
+ assert row["status"] == "pending"
+ assert row["worker_id"] is None
+ assert row["claimed_at"] is None
+
+ @pytest.mark.asyncio
+ async def test_recover_own_tasks_does_not_affect_other_workers(self, pool, clean_operations):
+ """Test that recover_own_tasks only affects tasks from the same worker_id."""
+ from hindsight_api.worker import WorkerPoller
+
+ bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
+
+ # Create tasks for worker-1 (the one that will recover)
+ for i in range(2):
+ op_id = uuid.uuid4()
+ payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
+ VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-1')
+ """,
+ op_id,
+ bank_id,
+ payload,
+ )
+
+ # Create tasks for worker-2 (should not be affected)
+ for i in range(2):
+ op_id = uuid.uuid4()
+ payload = json.dumps({"type": "test_task", "index": i + 10, "bank_id": bank_id})
+ await pool.execute(
+ """
+ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
+ VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-2')
+ """,
+ op_id,
+ bank_id,
+ payload,
+ )
+
+ # Worker-1 recovers its tasks
+ poller = WorkerPoller(
+ pool=pool,
+ worker_id="worker-1",
+ executor=lambda x: None,
+ )
+
+ recovered_count = await poller.recover_own_tasks()
+ assert recovered_count == 2
+
+ # Verify worker-1 tasks are released
+ worker1_rows = await pool.fetch(
+ "SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id IS NULL",
+ bank_id,
+ )
+ assert len(worker1_rows) == 2
+
+ # Verify worker-2 tasks are unaffected
+ worker2_rows = await pool.fetch(
+ "SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id = 'worker-2'",
+ bank_id,
+ )
+ assert len(worker2_rows) == 2
+ for row in worker2_rows:
+ assert row["status"] == "processing"
+
+ @pytest.mark.asyncio
+ async def test_recover_own_tasks_returns_zero_when_no_stale_tasks(self, pool, clean_operations):
+ """Test that recover_own_tasks returns 0 when there are no stale tasks."""
+ from hindsight_api.worker import WorkerPoller
+
+ poller = WorkerPoller(
+ pool=pool,
+ worker_id="fresh-worker",
+ executor=lambda x: None,
+ )
+
+ recovered_count = await poller.recover_own_tasks()
+ assert recovered_count == 0
+
class TestConcurrentWorkers:
"""Tests for concurrent worker task claiming (FOR UPDATE SKIP LOCKED)."""
diff --git a/hindsight-cli/smoke-test.sh b/hindsight-cli/smoke-test.sh
index 8b4fd171..55efca1b 100755
--- a/hindsight-cli/smoke-test.sh
+++ b/hindsight-cli/smoke-test.sh
@@ -115,31 +115,10 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED=
# Test 14: Clear memories
run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
-# Test 15: Health check
-run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1
-
-# Test 16: List memories (new command)
-run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1
-
-# Test 17: List tags
-run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1
-
-# Test 18: List mental models
-run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
-
-# Test 19: Create mental model
-run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1
-
-# Test 20: List mental models (should have one now)
-run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
-
-# Test 21: Bank graph
-run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1
-
-# Test 22: List operations
+# Test 15: List operations
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
-# Test 23: Delete bank
+# Test 16: Delete bank
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
echo ""
diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs
index 78d1b42f..0acbf080 100644
--- a/hindsight-cli/src/api.rs
+++ b/hindsight-cli/src/api.rs
@@ -173,7 +173,7 @@ impl ApiClient {
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option
World Facts
@@ -457,11 +649,21 @@ export function BankProfileView() {
{stats.nodes_by_fact_type?.experience || 0}
+
Mental Models
{mentalModelsCount}
+ {stats.total_mental_models || 0}
+
+ Directives
+
+ {directives.length}
+
+ {d.content}
+
+ No directives yet. Directives are hard rules that must be followed during reflect.
+
+ Showing {opsOffset + 1}-{Math.min(opsOffset + opsLimit, totalOperations)} of{" "}
+ {totalOperations}
+
- No background operations
+ No {opsStatusFilter ? `${opsStatusFilter} ` : ""}operations
+ Are you sure you want to clear all mental models for{" "}
+ {currentBank}?
+
+ This will delete all consolidated knowledge. Mental models will be regenerated the
+ next time consolidation runs.
+ This will delete {stats.total_mental_models} mental models.
-
+
+
-
+
+ This action cannot be undone.
+ {directive.name}
+
+ directive
+
+
+ {directive.id}
+
+
+ Comma-separated tags for filtering during recall/reflect +
- Comma-separated tags for filtering during recall/reflect -
-+ Comma-separated tags for filtering during recall/reflect +
+{uploadProgress}
+ )} +{docError}
} - {docError &&{docError}
} -{source.text}
+ {source.context && ( ++ Context: {source.context} +
+ )} +Select a memory bank to view mental models.
-- {mentalModels.length} mental model{mentalModels.length !== 1 ? "s" : ""} -
-Loading mental models...
-- No emergent models yet. Models are discovered from patterns in your data. -
-- No pinned models yet. Create custom models to track specific topics. -
-- No learned models yet. Models are created automatically during reflection. -
-- No directives yet. Directives are hard rules that the agent must follow. -
-- {!mission - ? "Set a mission above, then use the refresh buttons to create mental models." - : "Use the refresh buttons to create mental models. Click 'All' for both types, or refresh 'Structural' (from mission) and 'Emergent' (from data) separately."} -
-{model.description}
- - {/* Stats row - not shown for directives */} - {!isDirective && ( -- {children} -
- ), - ul: ({ children }) =>
- {children}
-
- ),
- strong: ({ children }) => (
- {children}
- ),
- }}
- >
- {observation.text || observation.content}
- - “{ev.quote}” -
- {ev.timestamp && ( - -{model.id}
- Select a memory bank to view reflections.
+Loading...
++ {searchQuery + ? "No reflections match your filter" + : "No reflections yet. Create a reflection to generate and save a summary from your memories."} +
+{reflection.source_query}
+ > + )} +{fact.text}
+{model.text}
++ No source data available. Click "Refresh" to regenerate with source + tracking. +
+
+ {reflection.id}
+
+ No memories found for this query.
+{mm.text}
+{result.text}
-No memories found for this query.
+{result.text}
+