Skip to content

Commit ea662d0

Browse files
authored
feat: fact_types and mental model exclusion filters for reflect (#615)
* feat: add fact_types and mental model exclusion filters to reflect and mental models Adds three new filtering options to both the reflect endpoint and mental model creation/refresh: - `fact_types`: restrict which fact types (world, experience, observation) are retrieved. Disables irrelevant agent tools entirely (no wasted tokens). - `exclude_mental_models`: skip the search_mental_models tool altogether. - `exclude_mental_model_ids`: exclude specific mental models by ID (merged with the existing self-exclusion logic during mental model refresh). For mental models, options are persisted in the existing `trigger` JSONB column so they are automatically applied on every refresh. The `UpdateMentalModelRequest` already proxies `trigger`, so no extra endpoint changes are needed. Also fixes the test fixture (`pg0_db_url` in conftest.py) to correctly resolve pg0:// URLs and run migrations before tests, which was causing all DB-dependent tests to fail with "relation public.banks does not exist" when HINDSIGHT_API_DATABASE_URL=pg0://uuuu. * fix: guard against disabled-tool hallucination and regenerate clients - Add enabled_tools guard in reflect agent: if an LLM calls a tool that was excluded (e.g. recall when fact_types=["observation"]), return an error result instead of executing it - Regenerate OpenAPI spec and all SDK clients (Go, Python, TypeScript) to include new fact_types / exclude_mental_models fields * fix: add missing ReflectRequest fields in Rust CLI struct initializers * fix: filter hallucinated tool calls before trace to prevent disabled tools appearing in results * chore: merge main, fix lint formatting and update skills openapi.json * feat: expose fact_types, exclude_mental_models, exclude_mental_model_ids in control plane UI * fix: add missing trigger fields to MentalModel type in control plane api.ts * fix: add missing trigger fields to local MentalModel interface in mental-models-view * feat: tabbed mental model dialogs (Basic / Options tabs) * refactor: shared FactTypeFilter component, tabbed mental model dialogs use General tab, clean up labels * feat: pill-style toggle buttons for fact type filter (blue/emerald/amber per type) * fix: add spacing between Fact Types label and pills, rename to Exclude all mental models
1 parent 94cf89b commit ea662d0

23 files changed

Lines changed: 1393 additions & 220 deletions

File tree

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,25 @@ class ReflectRequest(BaseModel):
669669
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
670670
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
671671
)
672+
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
673+
default=None,
674+
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
675+
)
676+
exclude_mental_models: bool = Field(
677+
default=False,
678+
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
679+
)
680+
exclude_mental_model_ids: list[str] | None = Field(
681+
default=None,
682+
description="Exclude specific mental models by ID from the reflect loop.",
683+
)
684+
685+
@field_validator("fact_types")
686+
@classmethod
687+
def validate_reflect_fact_types(cls, v: list[str] | None) -> list[str] | None:
688+
if v is not None and len(v) == 0:
689+
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
690+
return v
672691

673692
@model_validator(mode="after")
674693
def validate_tags_exclusive(self) -> "ReflectRequest":
@@ -1435,6 +1454,25 @@ class MentalModelTrigger(BaseModel):
14351454
default=False,
14361455
description="If true, refresh this mental model after observations consolidation (real-time mode)",
14371456
)
1457+
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
1458+
default=None,
1459+
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
1460+
)
1461+
exclude_mental_models: bool = Field(
1462+
default=False,
1463+
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
1464+
)
1465+
exclude_mental_model_ids: list[str] | None = Field(
1466+
default=None,
1467+
description="Exclude specific mental models by ID from the reflect loop.",
1468+
)
1469+
1470+
@field_validator("fact_types")
1471+
@classmethod
1472+
def validate_fact_types(cls, v: list[str] | None) -> list[str] | None:
1473+
if v is not None and len(v) == 0:
1474+
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
1475+
return v
14381476

14391477

14401478
class MentalModelResponse(BaseModel):
@@ -2505,6 +2543,9 @@ async def api_reflect(
25052543
tags=request.tags,
25062544
tags_match=request.tags_match,
25072545
tag_groups=request.tag_groups,
2546+
fact_types=request.fact_types,
2547+
exclude_mental_models=request.exclude_mental_models,
2548+
exclude_mental_model_ids=request.exclude_mental_model_ids,
25082549
)
25092550

25102551
# Build based_on (memories + mental_models + directives) if facts are requested

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -875,14 +875,23 @@ async def _handle_refresh_mental_model(self, task_dict: dict[str, Any]):
875875
tags = mental_model.get("tags")
876876
tags_match = "all_strict" if tags else "any"
877877

878+
# Read reflect options from trigger (if stored)
879+
trigger_data = mental_model.get("trigger") or {}
880+
fact_types = trigger_data.get("fact_types")
881+
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
882+
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
883+
878884
# Run reflect to generate new content, excluding the mental model being refreshed
885+
# Always add self to excluded IDs to prevent circular reference
879886
reflect_result = await self.reflect_async(
880887
bank_id=bank_id,
881888
query=source_query,
882889
request_context=internal_context,
883890
tags=tags,
884891
tags_match=tags_match,
885-
exclude_mental_model_ids=[mental_model_id],
892+
fact_types=fact_types,
893+
exclude_mental_models=exclude_mental_models,
894+
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
886895
)
887896

888897
generated_content = reflect_result.text or "No content generated"
@@ -5120,6 +5129,8 @@ async def reflect_async(
51205129
tags_match: TagsMatch = "any",
51215130
tag_groups: list[TagGroup] | None = None,
51225131
exclude_mental_model_ids: list[str] | None = None,
5132+
fact_types: list[str] | None = None,
5133+
exclude_mental_models: bool = False,
51235134
_skip_span: bool = False,
51245135
) -> ReflectResult:
51255136
"""
@@ -5240,6 +5251,11 @@ async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, An
52405251
pending_consolidation=pending_consolidation,
52415252
)
52425253

5254+
# Determine which tools to enable based on fact_types and exclude_mental_models
5255+
include_observations = fact_types is None or "observation" in fact_types
5256+
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
5257+
include_recall = bool(recall_fact_types)
5258+
52435259
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
52445260
return await tool_recall(
52455261
self,
@@ -5251,6 +5267,7 @@ async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000
52515267
tags_match=tags_match,
52525268
tag_groups=tag_groups,
52535269
max_chunk_tokens=max_chunk_tokens,
5270+
fact_types=recall_fact_types if fact_types is not None else None,
52545271
)
52555272

52565273
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
@@ -5273,15 +5290,17 @@ async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
52735290
if directives:
52745291
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
52755292

5276-
# Check if the bank has any mental models
5277-
async with pool.acquire() as conn:
5278-
mental_model_count = await conn.fetchval(
5279-
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
5280-
bank_id,
5281-
)
5282-
has_mental_models = mental_model_count > 0
5283-
if has_mental_models:
5284-
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
5293+
# Check if the bank has any mental models (skip check if all mental models are excluded)
5294+
has_mental_models = False
5295+
if not exclude_mental_models:
5296+
async with pool.acquire() as conn:
5297+
mental_model_count = await conn.fetchval(
5298+
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
5299+
bank_id,
5300+
)
5301+
has_mental_models = mental_model_count > 0
5302+
if has_mental_models:
5303+
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
52855304

52865305
# Run the agent with parent span for reflect operation (skip if called from another operation)
52875306
if not _skip_span:
@@ -5306,6 +5325,8 @@ async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
53065325
response_schema=response_schema,
53075326
directives=directives,
53085327
has_mental_models=has_mental_models,
5328+
include_observations=include_observations,
5329+
include_recall=include_recall,
53095330
budget=effective_budget,
53105331
max_context_tokens=max_context_tokens,
53115332
)
@@ -6437,6 +6458,12 @@ async def refresh_mental_model(
64376458
tags = mental_model.get("tags")
64386459
tags_match = "all_strict" if tags else "any"
64396460

6461+
# Read reflect options from trigger (if stored)
6462+
trigger_data = mental_model.get("trigger") or {}
6463+
fact_types = trigger_data.get("fact_types")
6464+
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
6465+
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
6466+
64406467
# Run reflect with the source query, excluding the mental model being refreshed
64416468
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
64426469
reflect_result = await self.reflect_async(
@@ -6445,7 +6472,9 @@ async def refresh_mental_model(
64456472
request_context=request_context,
64466473
tags=tags,
64476474
tags_match=tags_match,
6448-
exclude_mental_model_ids=[mental_model_id],
6475+
fact_types=fact_types,
6476+
exclude_mental_models=exclude_mental_models,
6477+
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
64496478
_skip_span=True,
64506479
)
64516480

hindsight-api-slim/hindsight_api/engine/reflect/agent.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ async def run_reflect_agent(
316316
response_schema: dict | None = None,
317317
directives: list[dict[str, Any]] | None = None,
318318
has_mental_models: bool = False,
319+
include_observations: bool = True,
320+
include_recall: bool = True,
319321
budget: str | None = None,
320322
max_context_tokens: int = 100_000,
321323
) -> ReflectAgentResult:
@@ -355,7 +357,14 @@ async def run_reflect_agent(
355357
directive_rules = _extract_directive_rules(directives) if directives else None
356358

357359
# Get tools for this agent (with directive compliance field if directives exist)
358-
tools = get_reflect_tools(directive_rules=directive_rules)
360+
tools = get_reflect_tools(
361+
directive_rules=directive_rules,
362+
include_mental_models=has_mental_models,
363+
include_observations=include_observations,
364+
include_recall=include_recall,
365+
)
366+
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
367+
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
359368

360369
# Build initial messages (directives are injected into system prompt at START and END)
361370
system_prompt = build_system_prompt_for_tools(
@@ -538,19 +547,18 @@ def _log_completion(answer: str, iterations: int, forced: bool = False):
538547
llm_start = time.time()
539548

540549
# Determine tool_choice for this iteration.
541-
# Force the full hierarchical retrieval path before allowing auto:
542-
# With mental models:
543-
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto
544-
# Without mental models:
545-
# 0 → search_observations, 1 → recall, 2+ → auto
546-
if iteration == 0 and has_mental_models:
547-
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}}
548-
elif iteration == 0:
549-
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
550-
elif iteration == 1 and has_mental_models:
551-
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
552-
elif iteration == 1 or (iteration == 2 and has_mental_models):
553-
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
550+
# Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
551+
# Build the forced sequence from the tools that are actually enabled.
552+
forced_sequence = []
553+
if has_mental_models:
554+
forced_sequence.append("search_mental_models")
555+
if include_observations:
556+
forced_sequence.append("search_observations")
557+
if include_recall:
558+
forced_sequence.append("recall")
559+
560+
if iteration < len(forced_sequence):
561+
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
554562
else:
555563
iter_tool_choice = "auto"
556564

@@ -769,14 +777,41 @@ def _log_completion(answer: str, iterations: int, forced: bool = False):
769777
# Execute other tools in parallel (exclude done tool in all its format variants)
770778
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
771779
if other_tools:
772-
# Add assistant message with tool calls
780+
# Partition into enabled vs hallucinated (not in enabled_tools set)
781+
allowed_tools = []
782+
hallucinated_tools = []
783+
for tc in other_tools:
784+
norm = _normalize_tool_name(tc.name)
785+
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
786+
hallucinated_tools.append(tc)
787+
else:
788+
allowed_tools.append(tc)
789+
790+
# Build assistant message with all tool calls (LLM requires them for history)
773791
messages.append(
774792
{
775793
"role": "assistant",
776794
"tool_calls": [_tool_call_to_dict(tc) for tc in other_tools],
777795
}
778796
)
779797

798+
# Immediately reject hallucinated tool calls without adding to trace
799+
for tc in hallucinated_tools:
800+
messages.append(
801+
{
802+
"role": "tool",
803+
"tool_call_id": tc.id,
804+
"name": tc.name,
805+
"content": json.dumps(
806+
{
807+
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
808+
}
809+
),
810+
}
811+
)
812+
813+
other_tools = allowed_tools
814+
780815
# Execute tools in parallel
781816
tool_tasks = [
782817
_execute_tool_with_timing(
@@ -785,6 +820,7 @@ def _log_completion(answer: str, iterations: int, forced: bool = False):
785820
search_observations_fn,
786821
recall_fn,
787822
expand_fn,
823+
enabled_tools=enabled_tools,
788824
)
789825
for tc in other_tools
790826
]
@@ -974,6 +1010,7 @@ async def _execute_tool_with_timing(
9741010
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
9751011
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
9761012
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
1013+
enabled_tools: frozenset[str] | None = None,
9771014
) -> tuple[dict[str, Any], int]:
9781015
"""Execute a tool call and return result with timing."""
9791016
from hindsight_api.tracing import get_tracer
@@ -1007,6 +1044,7 @@ async def _execute_tool_with_timing(
10071044
search_observations_fn,
10081045
recall_fn,
10091046
expand_fn,
1047+
enabled_tools=enabled_tools,
10101048
)
10111049

10121050
# Set success attributes
@@ -1046,11 +1084,16 @@ async def _execute_tool(
10461084
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
10471085
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
10481086
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
1087+
enabled_tools: frozenset[str] | None = None,
10491088
) -> dict[str, Any]:
10501089
"""Execute a single tool by name."""
10511090
# Normalize tool name for various LLM output formats
10521091
tool_name = _normalize_tool_name(tool_name)
10531092

1093+
# Guard against LLMs hallucinating calls to tools that were not provided
1094+
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
1095+
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
1096+
10541097
if tool_name == "search_mental_models":
10551098
query = args.get("query")
10561099
if not query:

hindsight-api-slim/hindsight_api/engine/reflect/tools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ async def tool_recall(
200200
tag_groups: "list | None" = None,
201201
connection_budget: int = 1,
202202
max_chunk_tokens: int = 1000,
203+
fact_types: list[str] | None = None,
203204
) -> dict[str, Any]:
204205
"""
205206
Search memories using TEMPR retrieval.
@@ -217,15 +218,18 @@ async def tool_recall(
217218
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
218219
connection_budget: Max DB connections for this recall (default 1 for internal ops)
219220
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
221+
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
220222
221223
Returns:
222224
Dict with list of matching memories including raw chunk text
223225
"""
226+
# Only world/experience are valid for raw recall (observation is handled by search_observations)
227+
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
224228
include_chunks = True
225229
result = await memory_engine.recall_async(
226230
bank_id=bank_id,
227231
query=query,
228-
fact_type=["experience", "world"],
232+
fact_type=recall_fact_type,
229233
max_tokens=max_tokens,
230234
enable_trace=False,
231235
request_context=request_context,

hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
227227
}
228228

229229

230-
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
230+
def get_reflect_tools(
231+
directive_rules: list[str] | None = None,
232+
include_mental_models: bool = True,
233+
include_observations: bool = True,
234+
include_recall: bool = True,
235+
) -> list[dict]:
231236
"""
232237
Get the list of tools for the reflect agent.
233238
@@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
239244
Args:
240245
directive_rules: Optional list of directive rule strings. If provided,
241246
the done() tool will require directive compliance confirmation.
247+
include_mental_models: Whether to include the search_mental_models tool.
248+
include_observations: Whether to include the search_observations tool.
249+
include_recall: Whether to include the recall tool.
242250
243251
Returns:
244252
List of tool definitions in OpenAI format
245253
"""
246-
tools = [
247-
TOOL_SEARCH_MENTAL_MODELS,
248-
TOOL_SEARCH_OBSERVATIONS,
249-
TOOL_RECALL,
250-
TOOL_EXPAND,
251-
]
254+
tools = []
255+
256+
if include_mental_models:
257+
tools.append(TOOL_SEARCH_MENTAL_MODELS)
258+
if include_observations:
259+
tools.append(TOOL_SEARCH_OBSERVATIONS)
260+
if include_recall:
261+
tools.append(TOOL_RECALL)
262+
263+
tools.append(TOOL_EXPAND)
252264

253265
# Use directive-aware done tool if directives are present
254266
if directive_rules:

0 commit comments

Comments
 (0)