Skip to content

Commit 278718d

Browse files
authored
fix: tagged directives should be applied to tagged mental models (#303)
* fix: tagged directives should be applied to tagged mental models * test: add unit test for based_on structure Verify that reflect returns the correct based_on structure with: - directives as dicts (id, name, content) in based_on.directives - mental models as MemoryFact objects in based_on.mental-models - memories separated properly This ensures directives and mental models are not mixed together in the API response.
1 parent 093ecff commit 278718d

File tree

4 files changed

+298
-75
lines changed

4 files changed

+298
-75
lines changed

hindsight-api/hindsight_api/api/http.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1916,17 +1916,17 @@ async def api_reflect(
19161916
directives = []
19171917
for fact_type, facts in core_result.based_on.items():
19181918
if fact_type == "directives":
1919-
# Directives have different structure (id, name, content)
1919+
# Directives are dicts with id, name, content (not MemoryFact objects)
19201920
for directive in facts:
19211921
directives.append(
19221922
ReflectDirective(
1923-
id=directive.id,
1924-
name=directive.name,
1925-
content=directive.content,
1923+
id=directive["id"],
1924+
name=directive["name"],
1925+
content=directive["content"],
19261926
)
19271927
)
1928-
elif fact_type == "mental_models":
1929-
# Mental models are MemoryFact with type "mental_models"
1928+
elif fact_type == "mental-models":
1929+
# Mental models are MemoryFact with type "mental-models" (note: hyphen, not underscore)
19301930
for fact in facts:
19311931
mental_models.append(
19321932
ReflectMentalModel(

hindsight-api/hindsight_api/engine/memory_engine.py

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3673,12 +3673,14 @@ async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
36733673

36743674
# Load directives from the dedicated directives table
36753675
# Directives are hard rules that must be followed in all responses
3676+
# Use isolation_mode=True to prevent tag-scoped directives from leaking into untagged operations
36763677
directives_raw = await self.list_directives(
36773678
bank_id=bank_id,
36783679
tags=tags,
36793680
tags_match=tags_match,
36803681
active_only=True,
36813682
request_context=request_context,
3683+
isolation_mode=True,
36823684
)
36833685
# Convert directive format to the expected format for reflect agent
36843686
# The agent expects: name, description (optional), observations (list of {title, content})
@@ -3747,7 +3749,16 @@ async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
37473749
# Extract memories from recall tool outputs - only include memories the agent actually used
37483750
# agent_result.used_memory_ids contains validated IDs from the done action
37493751
used_memory_ids_set = set(agent_result.used_memory_ids) if agent_result.used_memory_ids else set()
3750-
based_on: dict[str, list[MemoryFact]] = {"world": [], "experience": [], "opinion": [], "observation": []}
3752+
# based_on stores facts, mental models, and directives
3753+
# Note: directives list stores raw directive dicts (not MemoryFact), which will be converted to Directive objects
3754+
based_on: dict[str, list[MemoryFact] | list[dict[str, Any]]] = {
3755+
"world": [],
3756+
"experience": [],
3757+
"opinion": [],
3758+
"observation": [],
3759+
"mental-models": [],
3760+
"directives": [],
3761+
}
37513762
seen_memory_ids: set[str] = set()
37523763
for tc in agent_result.tool_trace:
37533764
if tc.tool == "recall" and "memories" in tc.output:
@@ -3849,38 +3860,15 @@ async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
38493860
)
38503861
# List all models lookup - don't add to based_on (too verbose, just a listing)
38513862

3852-
# Add directives to based_on["mental-models"] (they are mental models with subtype='directive')
3853-
for directive in directives:
3854-
# Extract summary from observations
3855-
summary_parts: list[str] = []
3856-
for obs in directive.get("observations", []):
3857-
# Support both Pydantic Observation objects and dicts
3858-
if hasattr(obs, "content"):
3859-
content = obs.content
3860-
title = obs.title
3861-
else:
3862-
content = obs.get("content", "")
3863-
title = obs.get("title", "")
3864-
if title and content:
3865-
summary_parts.append(f"{title}: {content}")
3866-
elif content:
3867-
summary_parts.append(content)
3868-
3869-
# Fallback to description if no observations
3870-
if not summary_parts and directive.get("description"):
3871-
summary_parts.append(directive["description"])
3872-
3873-
directive_name = directive.get("name", "")
3874-
directive_summary = "; ".join(summary_parts) if summary_parts else ""
3875-
based_on["mental-models"].append(
3876-
MemoryFact(
3877-
id=directive.get("id", ""),
3878-
text=f"{directive_name}: {directive_summary}",
3879-
fact_type="mental-models",
3880-
context="directive (directive)",
3881-
occurred_start=None,
3882-
occurred_end=None,
3883-
)
3863+
# Add directives to based_on["directives"]
3864+
# Store raw directive dicts (with id, name, content) for http.py to convert to ReflectDirective
3865+
for directive_raw in directives_raw:
3866+
based_on["directives"].append(
3867+
{
3868+
"id": directive_raw["id"],
3869+
"name": directive_raw["name"],
3870+
"content": directive_raw["content"],
3871+
}
38843872
)
38853873

38863874
# Build directives_applied from agent result
@@ -4754,6 +4742,7 @@ async def refresh_mental_model(
47544742
"id": str(fact.id),
47554743
"text": fact.text,
47564744
"type": fact_type,
4745+
"context": fact.context, # Include context to distinguish directives from mental models in UI
47574746
}
47584747
for fact in facts
47594748
]
@@ -4942,6 +4931,7 @@ async def list_directives(
49424931
limit: int = 100,
49434932
offset: int = 0,
49444933
request_context: "RequestContext",
4934+
isolation_mode: bool = False,
49454935
) -> list[dict[str, Any]]:
49464936
"""List directives for a bank.
49474937
@@ -4953,6 +4943,9 @@ async def list_directives(
49534943
limit: Maximum number of results
49544944
offset: Offset for pagination
49554945
request_context: Request context for authentication
4946+
isolation_mode: When True and tags=None, only return directives with no tags.
4947+
This prevents tag-scoped directives from leaking into untagged operations.
4948+
Default False (normal API behavior - returns all directives when tags=None)
49564949
49574950
Returns:
49584951
List of directive dicts
@@ -4962,22 +4955,32 @@ async def list_directives(
49624955

49634956
async with acquire_with_retry(pool) as conn:
49644957
# Build filters
4958+
from .search.tags import build_tags_where_clause
4959+
49654960
filters = ["bank_id = $1"]
49664961
params: list[Any] = [bank_id]
49674962
param_idx = 2
49684963

49694964
if active_only:
49704965
filters.append("is_active = TRUE")
49714966

4967+
# Apply tags filter:
4968+
# - If tags provided: use standard filtering (with strict modes support)
4969+
# - If tags=None and isolation_mode=True: only include directives with NO tags
4970+
# (prevents tag-scoped directives from leaking into untagged reflect/refresh)
4971+
# - If tags=None and isolation_mode=False: no filtering (normal API behavior)
49724972
if tags:
4973-
if tags_match == "all":
4974-
filters.append(f"tags @> ${param_idx}::varchar[]")
4975-
elif tags_match == "exact":
4976-
filters.append(f"tags = ${param_idx}::varchar[]")
4977-
else: # any
4978-
filters.append(f"tags && ${param_idx}::varchar[]")
4979-
params.append(tags)
4980-
param_idx += 1
4973+
tags_clause, tags_params, param_idx = build_tags_where_clause(
4974+
tags=tags, param_offset=param_idx, table_alias="", match=tags_match
4975+
)
4976+
if tags_clause:
4977+
# Remove leading "AND " from clause since we're building filters list
4978+
filters.append(tags_clause.replace("AND ", "", 1))
4979+
params.extend(tags_params)
4980+
elif isolation_mode:
4981+
# Isolation mode: only include directives with empty/null tags
4982+
# This ensures tag-scoped directives don't apply to untagged operations
4983+
filters.append("(tags IS NULL OR tags = '{}')")
49814984

49824985
params.extend([limit, offset])
49834986

hindsight-api/tests/test_mental_models.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,49 @@ async def test_list_directives_by_tags(self, memory: MemoryEngine, request_conte
312312
# Cleanup
313313
await memory.delete_bank(bank_id, request_context=request_context)
314314

315+
async def test_list_all_directives_without_filter(self, memory: MemoryEngine, request_context):
316+
"""Test that listing directives without tags returns ALL directives (both tagged and untagged)."""
317+
bank_id = f"test-directive-list-all-{uuid.uuid4().hex[:8]}"
318+
319+
# Ensure bank exists
320+
await memory.get_bank_profile(bank_id, request_context=request_context)
321+
322+
# Create untagged directive
323+
await memory.create_directive(
324+
bank_id=bank_id,
325+
name="Untagged Directive",
326+
content="This has no tags",
327+
request_context=request_context,
328+
)
329+
330+
# Create tagged directive
331+
await memory.create_directive(
332+
bank_id=bank_id,
333+
name="Tagged Directive",
334+
content="This has tags",
335+
tags=["project-x"],
336+
request_context=request_context,
337+
)
338+
339+
# List ALL directives (no tag filter, isolation_mode defaults to False)
340+
all_directives = await memory.list_directives(
341+
bank_id=bank_id,
342+
request_context=request_context,
343+
)
344+
345+
# Should return BOTH tagged and untagged directives
346+
assert len(all_directives) == 2
347+
directive_names = {d["name"] for d in all_directives}
348+
assert "Untagged Directive" in directive_names
349+
assert "Tagged Directive" in directive_names
350+
351+
# Verify the tagged directive has its tags
352+
tagged = next(d for d in all_directives if d["name"] == "Tagged Directive")
353+
assert tagged["tags"] == ["project-x"]
354+
355+
# Cleanup
356+
await memory.delete_bank(bank_id, request_context=request_context)
357+
315358

316359
class TestReflect:
317360
"""Test reflect endpoint."""
@@ -399,6 +442,161 @@ async def test_reflect_follows_language_directive(self, memory: MemoryEngine, re
399442
# Cleanup
400443
await memory.delete_bank(bank_id, request_context=request_context)
401444

445+
async def test_tagged_directive_not_applied_without_tags(self, memory: MemoryEngine, request_context):
446+
"""Test that directives with tags are NOT applied to untagged reflect operations."""
447+
bank_id = f"test-directive-isolation-{uuid.uuid4().hex[:8]}"
448+
449+
# Ensure bank exists
450+
await memory.get_bank_profile(bank_id, request_context=request_context)
451+
452+
# Add some untagged content
453+
await memory.retain_batch_async(
454+
bank_id=bank_id,
455+
contents=[
456+
{"content": "The sky is blue."},
457+
{"content": "Water is wet."},
458+
],
459+
request_context=request_context,
460+
)
461+
462+
# Add some tagged content for the project-x context
463+
await memory.retain_batch_async(
464+
bank_id=bank_id,
465+
contents=[
466+
{"content": "The sky is blue according to project X standards.", "tags": ["project-x"]},
467+
{"content": "Project X color guidelines specify sky is blue.", "tags": ["project-x"]},
468+
],
469+
request_context=request_context,
470+
)
471+
await memory.wait_for_background_tasks()
472+
473+
# Create an untagged directive (should be applied)
474+
await memory.create_directive(
475+
bank_id=bank_id,
476+
name="General Policy",
477+
content="Always be polite and start responses with 'Hello!'",
478+
request_context=request_context,
479+
)
480+
481+
# Create a tagged directive (should NOT be applied to untagged reflect)
482+
await memory.create_directive(
483+
bank_id=bank_id,
484+
name="Tagged Policy",
485+
content="ALWAYS respond in ALL CAPS and end with 'PROJECT-X ONLY'",
486+
tags=["project-x"],
487+
request_context=request_context,
488+
)
489+
490+
# Run reflect without tags - should only apply the untagged directive
491+
result = await memory.reflect_async(
492+
bank_id=bank_id,
493+
query="What color is the sky?",
494+
request_context=request_context,
495+
)
496+
497+
response_lower = result.text.lower()
498+
499+
# Should follow the untagged directive (polite greeting)
500+
assert "hello" in response_lower, f"Expected 'Hello' from untagged directive, but got: {result.text}"
501+
502+
# Should NOT follow the tagged directive (all caps and PROJECT-X)
503+
# If it did follow, the entire response would be in caps
504+
all_caps = result.text.replace(" ", "").replace("!", "").replace(".", "").isupper()
505+
assert not all_caps, f"Tagged directive was incorrectly applied to untagged operation: {result.text}"
506+
assert "project-x only" not in response_lower, f"Tagged directive was incorrectly applied: {result.text}"
507+
508+
# Now run reflect WITH the tag - should apply BOTH directives
509+
result_tagged = await memory.reflect_async(
510+
bank_id=bank_id,
511+
query="What color is the sky?",
512+
tags=["project-x"],
513+
tags_match="all_strict",
514+
request_context=request_context,
515+
)
516+
517+
response_tagged_lower = result_tagged.text.lower()
518+
519+
# With strict matching and tags, should apply the tagged directive
520+
assert "project-x only" in response_tagged_lower, f"Tagged directive should be applied with tags: {result_tagged.text}"
521+
522+
# Cleanup
523+
await memory.delete_bank(bank_id, request_context=request_context)
524+
525+
async def test_reflect_based_on_structure(self, memory: MemoryEngine, request_context):
526+
"""Test that reflect returns correct based_on structure with directives and memories separated."""
527+
bank_id = f"test-reflect-based-on-{uuid.uuid4().hex[:8]}"
528+
529+
# Ensure bank exists
530+
await memory.get_bank_profile(bank_id, request_context=request_context)
531+
532+
# Add some memories
533+
await memory.retain_batch_async(
534+
bank_id=bank_id,
535+
contents=[
536+
{"content": "Alice works at Google as a software engineer."},
537+
{"content": "Bob is a product manager at Microsoft."},
538+
{"content": "The team meets every Monday at 9am."},
539+
],
540+
request_context=request_context,
541+
)
542+
await memory.wait_for_background_tasks()
543+
544+
# Create a directive
545+
directive = await memory.create_directive(
546+
bank_id=bank_id,
547+
name="Professional Tone",
548+
content="Always maintain a professional and formal tone in responses.",
549+
request_context=request_context,
550+
)
551+
directive_id = directive["id"]
552+
553+
# Run reflect which returns the core result
554+
result = await memory.reflect_async(
555+
bank_id=bank_id,
556+
query="Who works at Google?",
557+
request_context=request_context,
558+
)
559+
560+
# Verify based_on structure exists
561+
assert result.based_on is not None
562+
563+
# Verify directives key exists and contains our directive
564+
assert "directives" in result.based_on
565+
directives_list = result.based_on.get("directives", [])
566+
567+
# Verify directives are dicts with id, name, content (not MemoryFact objects)
568+
assert len(directives_list) > 0, "Should have at least one directive"
569+
directive_found = False
570+
for d in directives_list:
571+
assert isinstance(d, dict), f"Directive should be dict, got {type(d)}"
572+
assert "id" in d, "Directive dict should have 'id'"
573+
assert "name" in d, "Directive dict should have 'name'"
574+
assert "content" in d, "Directive dict should have 'content'"
575+
# Check if this is our directive
576+
if d["id"] == directive_id:
577+
directive_found = True
578+
assert d["name"] == "Professional Tone"
579+
assert "professional" in d["content"].lower()
580+
581+
assert directive_found, f"Our directive {directive_id} should be in based_on.directives"
582+
583+
# Verify memories (world/experience) are separate from directives
584+
has_memories = "world" in result.based_on or "experience" in result.based_on
585+
assert has_memories, "Should have world or experience memories"
586+
587+
# Verify that if mental-models key exists, it's separate from directives
588+
if "mental-models" in result.based_on:
589+
mental_models = result.based_on.get("mental-models", [])
590+
# Verify mental models are MemoryFact objects, not dicts like directives
591+
for mm in mental_models:
592+
assert hasattr(mm, "fact_type"), "Mental model should be MemoryFact with fact_type"
593+
assert mm.fact_type == "mental-models"
594+
assert hasattr(mm, "context")
595+
assert "mental model" in mm.context.lower()
596+
597+
# Cleanup
598+
await memory.delete_bank(bank_id, request_context=request_context)
599+
402600

403601
class TestDirectivesPromptInjection:
404602
"""Test that directives are properly injected into the system prompt."""

0 commit comments

Comments
 (0)