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
This commit is contained in:
Nicolò Boschi 2026-03-19 17:03:41 +01:00 committed by GitHub
parent 94cf89b570
commit ea662d062e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1393 additions and 220 deletions

View file

@ -669,6 +669,25 @@ class ReflectRequest(BaseModel):
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. " description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.", "Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
) )
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
default=None,
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
)
exclude_mental_models: bool = Field(
default=False,
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
)
exclude_mental_model_ids: list[str] | None = Field(
default=None,
description="Exclude specific mental models by ID from the reflect loop.",
)
@field_validator("fact_types")
@classmethod
def validate_reflect_fact_types(cls, v: list[str] | None) -> list[str] | None:
if v is not None and len(v) == 0:
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
return v
@model_validator(mode="after") @model_validator(mode="after")
def validate_tags_exclusive(self) -> "ReflectRequest": def validate_tags_exclusive(self) -> "ReflectRequest":
@ -1435,6 +1454,25 @@ class MentalModelTrigger(BaseModel):
default=False, default=False,
description="If true, refresh this mental model after observations consolidation (real-time mode)", description="If true, refresh this mental model after observations consolidation (real-time mode)",
) )
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
default=None,
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
)
exclude_mental_models: bool = Field(
default=False,
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
)
exclude_mental_model_ids: list[str] | None = Field(
default=None,
description="Exclude specific mental models by ID from the reflect loop.",
)
@field_validator("fact_types")
@classmethod
def validate_fact_types(cls, v: list[str] | None) -> list[str] | None:
if v is not None and len(v) == 0:
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
return v
class MentalModelResponse(BaseModel): class MentalModelResponse(BaseModel):
@ -2505,6 +2543,9 @@ def _register_routes(app: FastAPI):
tags=request.tags, tags=request.tags,
tags_match=request.tags_match, tags_match=request.tags_match,
tag_groups=request.tag_groups, tag_groups=request.tag_groups,
fact_types=request.fact_types,
exclude_mental_models=request.exclude_mental_models,
exclude_mental_model_ids=request.exclude_mental_model_ids,
) )
# Build based_on (memories + mental_models + directives) if facts are requested # Build based_on (memories + mental_models + directives) if facts are requested

View file

@ -875,14 +875,23 @@ class MemoryEngine(MemoryEngineInterface):
tags = mental_model.get("tags") tags = mental_model.get("tags")
tags_match = "all_strict" if tags else "any" tags_match = "all_strict" if tags else "any"
# Read reflect options from trigger (if stored)
trigger_data = mental_model.get("trigger") or {}
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
# Run reflect to generate new content, excluding the mental model being refreshed # Run reflect to generate new content, excluding the mental model being refreshed
# Always add self to excluded IDs to prevent circular reference
reflect_result = await self.reflect_async( reflect_result = await self.reflect_async(
bank_id=bank_id, bank_id=bank_id,
query=source_query, query=source_query,
request_context=internal_context, request_context=internal_context,
tags=tags, tags=tags,
tags_match=tags_match, tags_match=tags_match,
exclude_mental_model_ids=[mental_model_id], fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
) )
generated_content = reflect_result.text or "No content generated" generated_content = reflect_result.text or "No content generated"
@ -5120,6 +5129,8 @@ class MemoryEngine(MemoryEngineInterface):
tags_match: TagsMatch = "any", tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None, tag_groups: list[TagGroup] | None = None,
exclude_mental_model_ids: list[str] | None = None, exclude_mental_model_ids: list[str] | None = None,
fact_types: list[str] | None = None,
exclude_mental_models: bool = False,
_skip_span: bool = False, _skip_span: bool = False,
) -> ReflectResult: ) -> ReflectResult:
""" """
@ -5240,6 +5251,11 @@ class MemoryEngine(MemoryEngineInterface):
pending_consolidation=pending_consolidation, pending_consolidation=pending_consolidation,
) )
# Determine which tools to enable based on fact_types and exclude_mental_models
include_observations = fact_types is None or "observation" in fact_types
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
include_recall = bool(recall_fact_types)
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]: async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
return await tool_recall( return await tool_recall(
self, self,
@ -5251,6 +5267,7 @@ class MemoryEngine(MemoryEngineInterface):
tags_match=tags_match, tags_match=tags_match,
tag_groups=tag_groups, tag_groups=tag_groups,
max_chunk_tokens=max_chunk_tokens, max_chunk_tokens=max_chunk_tokens,
fact_types=recall_fact_types if fact_types is not None else None,
) )
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]: async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
@ -5273,15 +5290,17 @@ class MemoryEngine(MemoryEngineInterface):
if directives: if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives") logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
# Check if the bank has any mental models # Check if the bank has any mental models (skip check if all mental models are excluded)
async with pool.acquire() as conn: has_mental_models = False
mental_model_count = await conn.fetchval( if not exclude_mental_models:
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1", async with pool.acquire() as conn:
bank_id, mental_model_count = await conn.fetchval(
) f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
has_mental_models = mental_model_count > 0 bank_id,
if has_mental_models: )
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models") has_mental_models = mental_model_count > 0
if has_mental_models:
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
# Run the agent with parent span for reflect operation (skip if called from another operation) # Run the agent with parent span for reflect operation (skip if called from another operation)
if not _skip_span: if not _skip_span:
@ -5306,6 +5325,8 @@ class MemoryEngine(MemoryEngineInterface):
response_schema=response_schema, response_schema=response_schema,
directives=directives, directives=directives,
has_mental_models=has_mental_models, has_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
budget=effective_budget, budget=effective_budget,
max_context_tokens=max_context_tokens, max_context_tokens=max_context_tokens,
) )
@ -6437,6 +6458,12 @@ class MemoryEngine(MemoryEngineInterface):
tags = mental_model.get("tags") tags = mental_model.get("tags")
tags_match = "all_strict" if tags else "any" tags_match = "all_strict" if tags else "any"
# Read reflect options from trigger (if stored)
trigger_data = mental_model.get("trigger") or {}
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
# Run reflect with the source query, excluding the mental model being refreshed # Run reflect with the source query, excluding the mental model being refreshed
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh" # Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
reflect_result = await self.reflect_async( reflect_result = await self.reflect_async(
@ -6445,7 +6472,9 @@ class MemoryEngine(MemoryEngineInterface):
request_context=request_context, request_context=request_context,
tags=tags, tags=tags,
tags_match=tags_match, tags_match=tags_match,
exclude_mental_model_ids=[mental_model_id], fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
_skip_span=True, _skip_span=True,
) )

View file

@ -316,6 +316,8 @@ async def run_reflect_agent(
response_schema: dict | None = None, response_schema: dict | None = None,
directives: list[dict[str, Any]] | None = None, directives: list[dict[str, Any]] | None = None,
has_mental_models: bool = False, has_mental_models: bool = False,
include_observations: bool = True,
include_recall: bool = True,
budget: str | None = None, budget: str | None = None,
max_context_tokens: int = 100_000, max_context_tokens: int = 100_000,
) -> ReflectAgentResult: ) -> ReflectAgentResult:
@ -355,7 +357,14 @@ async def run_reflect_agent(
directive_rules = _extract_directive_rules(directives) if directives else None directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist) # Get tools for this agent (with directive compliance field if directives exist)
tools = get_reflect_tools(directive_rules=directive_rules) tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
)
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
# Build initial messages (directives are injected into system prompt at START and END) # Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools( system_prompt = build_system_prompt_for_tools(
@ -538,19 +547,18 @@ async def run_reflect_agent(
llm_start = time.time() llm_start = time.time()
# Determine tool_choice for this iteration. # Determine tool_choice for this iteration.
# Force the full hierarchical retrieval path before allowing auto: # Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
# With mental models: # Build the forced sequence from the tools that are actually enabled.
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto forced_sequence = []
# Without mental models: if has_mental_models:
# 0 → search_observations, 1 → recall, 2+ → auto forced_sequence.append("search_mental_models")
if iteration == 0 and has_mental_models: if include_observations:
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}} forced_sequence.append("search_observations")
elif iteration == 0: if include_recall:
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}} forced_sequence.append("recall")
elif iteration == 1 and has_mental_models:
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}} if iteration < len(forced_sequence):
elif iteration == 1 or (iteration == 2 and has_mental_models): iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
else: else:
iter_tool_choice = "auto" iter_tool_choice = "auto"
@ -769,7 +777,17 @@ async def run_reflect_agent(
# Execute other tools in parallel (exclude done tool in all its format variants) # 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)] other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
if other_tools: if other_tools:
# Add assistant message with tool calls # Partition into enabled vs hallucinated (not in enabled_tools set)
allowed_tools = []
hallucinated_tools = []
for tc in other_tools:
norm = _normalize_tool_name(tc.name)
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
hallucinated_tools.append(tc)
else:
allowed_tools.append(tc)
# Build assistant message with all tool calls (LLM requires them for history)
messages.append( messages.append(
{ {
"role": "assistant", "role": "assistant",
@ -777,6 +795,23 @@ async def run_reflect_agent(
} }
) )
# Immediately reject hallucinated tool calls without adding to trace
for tc in hallucinated_tools:
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"content": json.dumps(
{
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
}
),
}
)
other_tools = allowed_tools
# Execute tools in parallel # Execute tools in parallel
tool_tasks = [ tool_tasks = [
_execute_tool_with_timing( _execute_tool_with_timing(
@ -785,6 +820,7 @@ async def run_reflect_agent(
search_observations_fn, search_observations_fn,
recall_fn, recall_fn,
expand_fn, expand_fn,
enabled_tools=enabled_tools,
) )
for tc in other_tools for tc in other_tools
] ]
@ -974,6 +1010,7 @@ async def _execute_tool_with_timing(
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]], search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]], recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]], expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
enabled_tools: frozenset[str] | None = None,
) -> tuple[dict[str, Any], int]: ) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing.""" """Execute a tool call and return result with timing."""
from hindsight_api.tracing import get_tracer from hindsight_api.tracing import get_tracer
@ -1007,6 +1044,7 @@ async def _execute_tool_with_timing(
search_observations_fn, search_observations_fn,
recall_fn, recall_fn,
expand_fn, expand_fn,
enabled_tools=enabled_tools,
) )
# Set success attributes # Set success attributes
@ -1046,11 +1084,16 @@ async def _execute_tool(
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]], search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]], recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]], expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
enabled_tools: frozenset[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Execute a single tool by name.""" """Execute a single tool by name."""
# Normalize tool name for various LLM output formats # Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name) tool_name = _normalize_tool_name(tool_name)
# Guard against LLMs hallucinating calls to tools that were not provided
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
if tool_name == "search_mental_models": if tool_name == "search_mental_models":
query = args.get("query") query = args.get("query")
if not query: if not query:

View file

@ -200,6 +200,7 @@ async def tool_recall(
tag_groups: "list | None" = None, tag_groups: "list | None" = None,
connection_budget: int = 1, connection_budget: int = 1,
max_chunk_tokens: int = 1000, max_chunk_tokens: int = 1000,
fact_types: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Search memories using TEMPR retrieval. Search memories using TEMPR retrieval.
@ -217,15 +218,18 @@ async def tool_recall(
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact" tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
connection_budget: Max DB connections for this recall (default 1 for internal ops) connection_budget: Max DB connections for this recall (default 1 for internal ops)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included) max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
Returns: Returns:
Dict with list of matching memories including raw chunk text Dict with list of matching memories including raw chunk text
""" """
# Only world/experience are valid for raw recall (observation is handled by search_observations)
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
include_chunks = True include_chunks = True
result = await memory_engine.recall_async( result = await memory_engine.recall_async(
bank_id=bank_id, bank_id=bank_id,
query=query, query=query,
fact_type=["experience", "world"], fact_type=recall_fact_type,
max_tokens=max_tokens, max_tokens=max_tokens,
enable_trace=False, enable_trace=False,
request_context=request_context, request_context=request_context,

View file

@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
} }
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]: def get_reflect_tools(
directive_rules: list[str] | None = None,
include_mental_models: bool = True,
include_observations: bool = True,
include_recall: bool = True,
) -> list[dict]:
""" """
Get the list of tools for the reflect agent. Get the list of tools for the reflect agent.
@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
Args: Args:
directive_rules: Optional list of directive rule strings. If provided, directive_rules: Optional list of directive rule strings. If provided,
the done() tool will require directive compliance confirmation. the done() tool will require directive compliance confirmation.
include_mental_models: Whether to include the search_mental_models tool.
include_observations: Whether to include the search_observations tool.
include_recall: Whether to include the recall tool.
Returns: Returns:
List of tool definitions in OpenAI format List of tool definitions in OpenAI format
""" """
tools = [ tools = []
TOOL_SEARCH_MENTAL_MODELS,
TOOL_SEARCH_OBSERVATIONS, if include_mental_models:
TOOL_RECALL, tools.append(TOOL_SEARCH_MENTAL_MODELS)
TOOL_EXPAND, if include_observations:
] tools.append(TOOL_SEARCH_OBSERVATIONS)
if include_recall:
tools.append(TOOL_RECALL)
tools.append(TOOL_EXPAND)
# Use directive-aware done tool if directives are present # Use directive-aware done tool if directives are present
if directive_rules: if directive_rules:

View file

@ -48,7 +48,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
Session-scoped fixture that ensures pg0 is running, migrations are applied, Session-scoped fixture that ensures pg0 is running, migrations are applied,
and returns the database URL. and returns the database URL.
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management). If HINDSIGHT_API_DATABASE_URL is a plain postgresql:// URL, uses it directly.
If HINDSIGHT_API_DATABASE_URL is a pg0:// URL, resolves it to a real URL first.
Otherwise, starts pg0 once for the entire test session. Otherwise, starts pg0 once for the entire test session.
Uses filelock to ensure only one pytest-xdist worker starts pg0. Uses filelock to ensure only one pytest-xdist worker starts pg0.
@ -58,10 +59,23 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate
processes that share the same pg0 instance. pg0 will persist for the next test run. processes that share the same pg0 instance. pg0 will persist for the next test run.
""" """
if db_url: from hindsight_api.pg0 import parse_pg0_url as _parse_pg0_url
# Use provided database URL directly
# Determine pg0 instance name/port from db_url (if it's a pg0:// URL) or use defaults
if db_url and not _parse_pg0_url(db_url)[0]:
# Plain postgresql:// URL - use it directly but still run migrations
from hindsight_api.migrations import run_migrations
run_migrations(db_url)
return db_url return db_url
if db_url:
_, pg0_name, pg0_port = _parse_pg0_url(db_url)
pg0_instance_name = pg0_name or DEFAULT_PG0_INSTANCE_NAME
pg0_instance_port = pg0_port or DEFAULT_PG0_PORT
else:
pg0_instance_name = DEFAULT_PG0_INSTANCE_NAME
pg0_instance_port = DEFAULT_PG0_PORT
# Get shared temp dir for coordination between xdist workers # Get shared temp dir for coordination between xdist workers
if worker_id == "master": if worker_id == "master":
# Running without xdist (-n 0 or no -n flag) # Running without xdist (-n 0 or no -n flag)
@ -71,8 +85,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
root_tmp_dir = tmp_path_factory.getbasetemp().parent root_tmp_dir = tmp_path_factory.getbasetemp().parent
# Use a lock file to ensure only one worker starts pg0 # Use a lock file to ensure only one worker starts pg0
lock_file = root_tmp_dir / "pg0_setup.lock" lock_file = root_tmp_dir / f"pg0_setup_{pg0_instance_name}.lock"
url_file = root_tmp_dir / "pg0_url.txt" url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt"
with filelock.FileLock(str(lock_file)): with filelock.FileLock(str(lock_file)):
if url_file.exists(): if url_file.exists():
@ -80,7 +94,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
url = url_file.read_text().strip() url = url_file.read_text().strip()
else: else:
# First worker - start pg0 # First worker - start pg0
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT) pg0 = EmbeddedPostgres(name=pg0_instance_name, port=pg0_instance_port)
# Run ensure_running in a new event loop # Run ensure_running in a new event loop
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()

View file

@ -485,3 +485,206 @@ class TestReflectUsesMentalModels:
# Cleanup # Cleanup
await memory.delete_bank(bank_id, request_context=request_context) await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelReflectOptions:
"""Tests for fact_types and exclude_mental_models options stored in the trigger field."""
@pytest.mark.asyncio
async def test_trigger_stores_fact_types(self, memory: MemoryEngine, request_context):
"""Trigger field persists fact_types and returns them via get_mental_model."""
bank_id = f"test-mm-trigger-ft-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Observations only",
source_query="Summarize observations",
content="content",
trigger={"refresh_after_consolidation": False, "fact_types": ["observation"]},
request_context=request_context,
)
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
assert fetched["trigger"]["fact_types"] == ["observation"]
assert fetched["trigger"]["refresh_after_consolidation"] is False
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_trigger_stores_exclude_mental_models(self, memory: MemoryEngine, request_context):
"""Trigger field persists exclude_mental_models flag."""
bank_id = f"test-mm-trigger-em-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="No mental models",
source_query="Summarize raw facts",
content="content",
trigger={"refresh_after_consolidation": False, "exclude_mental_models": True},
request_context=request_context,
)
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
assert fetched["trigger"]["exclude_mental_models"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_trigger_stores_exclude_mental_model_ids(self, memory: MemoryEngine, request_context):
"""Trigger field persists exclude_mental_model_ids list."""
bank_id = f"test-mm-trigger-eid-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
excluded_ids = ["mm-abc", "mm-xyz"]
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Exclude some models",
source_query="Summarize",
content="content",
trigger={"refresh_after_consolidation": False, "exclude_mental_model_ids": excluded_ids},
request_context=request_context,
)
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
assert fetched["trigger"]["exclude_mental_model_ids"] == excluded_ids
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_trigger_reflect_options(self, memory: MemoryEngine, request_context):
"""update_mental_model persists updated trigger reflect options."""
bank_id = f"test-mm-trigger-upd-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Initially no filter",
source_query="Summarize",
content="content",
trigger={"refresh_after_consolidation": False},
request_context=request_context,
)
updated = await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
trigger={
"refresh_after_consolidation": True,
"fact_types": ["world", "experience"],
"exclude_mental_models": False,
"exclude_mental_model_ids": ["mm-skip"],
},
request_context=request_context,
)
assert updated["trigger"]["refresh_after_consolidation"] is True
assert updated["trigger"]["fact_types"] == ["world", "experience"]
assert updated["trigger"]["exclude_mental_models"] is False
assert updated["trigger"]["exclude_mental_model_ids"] == ["mm-skip"]
await memory.delete_bank(bank_id, request_context=request_context)
class TestReflectFactTypeFiltering:
"""Tests for fact_types and exclude_mental_models filtering in reflect_async."""
@pytest.mark.asyncio
async def test_exclude_mental_models_skips_search_mental_models_tool(
self, memory: MemoryEngine, request_context
):
"""When exclude_mental_models=True, search_mental_models is never called."""
bank_id = f"test-reflect-exmm-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model so the bank has one
await memory.create_mental_model(
bank_id=bank_id,
name="Existing Model",
source_query="Q",
content="Some content about the team",
request_context=request_context,
)
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me about the team",
request_context=request_context,
exclude_mental_models=True,
)
tool_names = [tc.tool for tc in result.tool_trace]
assert "search_mental_models" not in tool_names, (
f"search_mental_models should be excluded but found in: {tool_names}"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_exclude_observations_via_fact_types(self, memory: MemoryEngine, request_context):
"""When fact_types excludes observation, search_observations is never called."""
bank_id = f"test-reflect-exobs-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me something",
request_context=request_context,
fact_types=["world", "experience"],
)
tool_names = [tc.tool for tc in result.tool_trace]
assert "search_observations" not in tool_names, (
f"search_observations should be excluded but found in: {tool_names}"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_observation_only_fact_types_skips_recall(self, memory: MemoryEngine, request_context):
"""When fact_types=['observation'], recall is never called."""
bank_id = f"test-reflect-obsonly-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me something",
request_context=request_context,
fact_types=["observation"],
)
tool_names = [tc.tool for tc in result.tool_trace]
assert "recall" not in tool_names, f"recall should be excluded but found in: {tool_names}"
await memory.delete_bank(bank_id, request_context=request_context)
class TestReflectRequestValidation:
"""Tests for ReflectRequest and MentalModelTrigger validation via the HTTP API."""
@pytest.mark.asyncio
async def test_reflect_empty_fact_types_rejected(self, api_client, test_bank_id):
"""Passing fact_types=[] to reflect must return 422."""
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflect",
json={"query": "test", "fact_types": []},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_mental_model_empty_fact_types_rejected(self, api_client, test_bank_id):
"""Passing fact_types=[] inside trigger must return 422."""
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/mental-models",
json={
"name": "Test",
"source_query": "Q",
"trigger": {"refresh_after_consolidation": False, "fact_types": []},
},
)
assert response.status_code == 422

View file

@ -363,6 +363,9 @@ impl App {
tags: None, tags: None,
tags_match: TagsMatch::Any, tags_match: TagsMatch::Any,
tag_groups: None, tag_groups: None,
fact_types: None,
exclude_mental_models: false,
exclude_mental_model_ids: None,
}; };
let result = client.reflect(&bank_id, &request, false) let result = client.reflect(&bank_id, &request, false)

View file

@ -366,6 +366,9 @@ pub fn reflect(
tags: if tags.is_empty() { None } else { Some(tags) }, tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match), tags_match: parse_tags_match(&tags_match),
tag_groups: None, tag_groups: None,
fact_types: None,
exclude_mental_models: false,
exclude_mental_model_ids: None,
}; };
let response = client.reflect(agent_id, &request, verbose); let response = client.reflect(agent_id, &request, verbose);

View file

@ -4074,6 +4074,13 @@ components:
id: id id: id
trigger: trigger:
refresh_after_consolidation: false refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
last_refreshed_at: last_refreshed_at last_refreshed_at: last_refreshed_at
content: content content: content
tags: tags:
@ -4089,6 +4096,13 @@ components:
id: id id: id
trigger: trigger:
refresh_after_consolidation: false refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
last_refreshed_at: last_refreshed_at last_refreshed_at: last_refreshed_at
content: content content: content
tags: tags:
@ -4115,6 +4129,13 @@ components:
id: id id: id
trigger: trigger:
refresh_after_consolidation: false refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
last_refreshed_at: last_refreshed_at last_refreshed_at: last_refreshed_at
content: content content: content
tags: tags:
@ -4169,6 +4190,13 @@ components:
description: Trigger settings for a mental model. description: Trigger settings for a mental model.
example: example:
refresh_after_consolidation: false refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
properties: properties:
refresh_after_consolidation: refresh_after_consolidation:
default: false default: false
@ -4176,6 +4204,26 @@ components:
\ (real-time mode)" \ (real-time mode)"
title: Refresh After Consolidation title: Refresh After Consolidation
type: boolean type: boolean
fact_types:
items:
enum:
- world
- experience
- observation
type: string
nullable: true
type: array
exclude_mental_models:
default: false
description: "If true, exclude all mental models from the reflect loop (skip\
\ search_mental_models tool)."
title: Exclude Mental Models
type: boolean
exclude_mental_model_ids:
items:
type: string
nullable: true
type: array
title: MentalModelTrigger title: MentalModelTrigger
OperationResponse: OperationResponse:
description: Response model for a single async operation. description: Response model for a single async operation.
@ -4684,6 +4732,26 @@ components:
$ref: '#/components/schemas/RecallRequest_tag_groups_inner' $ref: '#/components/schemas/RecallRequest_tag_groups_inner'
nullable: true nullable: true
type: array type: array
fact_types:
items:
enum:
- world
- experience
- observation
type: string
nullable: true
type: array
exclude_mental_models:
default: false
description: "If true, exclude all mental models from the reflect loop (skip\
\ search_mental_models tool)."
title: Exclude Mental Models
type: boolean
exclude_mental_model_ids:
items:
type: string
nullable: true
type: array
required: required:
- query - query
title: ReflectRequest title: ReflectRequest

View file

@ -21,6 +21,10 @@ var _ MappedNullable = &MentalModelTrigger{}
type MentalModelTrigger struct { type MentalModelTrigger struct {
// If true, refresh this mental model after observations consolidation (real-time mode) // If true, refresh this mental model after observations consolidation (real-time mode)
RefreshAfterConsolidation *bool `json:"refresh_after_consolidation,omitempty"` RefreshAfterConsolidation *bool `json:"refresh_after_consolidation,omitempty"`
FactTypes []string `json:"fact_types,omitempty"`
// If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
ExcludeMentalModels *bool `json:"exclude_mental_models,omitempty"`
ExcludeMentalModelIds []string `json:"exclude_mental_model_ids,omitempty"`
} }
// NewMentalModelTrigger instantiates a new MentalModelTrigger object // NewMentalModelTrigger instantiates a new MentalModelTrigger object
@ -31,6 +35,8 @@ func NewMentalModelTrigger() *MentalModelTrigger {
this := MentalModelTrigger{} this := MentalModelTrigger{}
var refreshAfterConsolidation bool = false var refreshAfterConsolidation bool = false
this.RefreshAfterConsolidation = &refreshAfterConsolidation this.RefreshAfterConsolidation = &refreshAfterConsolidation
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this return &this
} }
@ -41,6 +47,8 @@ func NewMentalModelTriggerWithDefaults() *MentalModelTrigger {
this := MentalModelTrigger{} this := MentalModelTrigger{}
var refreshAfterConsolidation bool = false var refreshAfterConsolidation bool = false
this.RefreshAfterConsolidation = &refreshAfterConsolidation this.RefreshAfterConsolidation = &refreshAfterConsolidation
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this return &this
} }
@ -76,6 +84,104 @@ func (o *MentalModelTrigger) SetRefreshAfterConsolidation(v bool) {
o.RefreshAfterConsolidation = &v o.RefreshAfterConsolidation = &v
} }
// GetFactTypes returns the FactTypes field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *MentalModelTrigger) GetFactTypes() []string {
if o == nil {
var ret []string
return ret
}
return o.FactTypes
}
// GetFactTypesOk returns a tuple with the FactTypes field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *MentalModelTrigger) GetFactTypesOk() ([]string, bool) {
if o == nil || IsNil(o.FactTypes) {
return nil, false
}
return o.FactTypes, true
}
// HasFactTypes returns a boolean if a field has been set.
func (o *MentalModelTrigger) HasFactTypes() bool {
if o != nil && !IsNil(o.FactTypes) {
return true
}
return false
}
// SetFactTypes gets a reference to the given []string and assigns it to the FactTypes field.
func (o *MentalModelTrigger) SetFactTypes(v []string) {
o.FactTypes = v
}
// GetExcludeMentalModels returns the ExcludeMentalModels field value if set, zero value otherwise.
func (o *MentalModelTrigger) GetExcludeMentalModels() bool {
if o == nil || IsNil(o.ExcludeMentalModels) {
var ret bool
return ret
}
return *o.ExcludeMentalModels
}
// GetExcludeMentalModelsOk returns a tuple with the ExcludeMentalModels field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *MentalModelTrigger) GetExcludeMentalModelsOk() (*bool, bool) {
if o == nil || IsNil(o.ExcludeMentalModels) {
return nil, false
}
return o.ExcludeMentalModels, true
}
// HasExcludeMentalModels returns a boolean if a field has been set.
func (o *MentalModelTrigger) HasExcludeMentalModels() bool {
if o != nil && !IsNil(o.ExcludeMentalModels) {
return true
}
return false
}
// SetExcludeMentalModels gets a reference to the given bool and assigns it to the ExcludeMentalModels field.
func (o *MentalModelTrigger) SetExcludeMentalModels(v bool) {
o.ExcludeMentalModels = &v
}
// GetExcludeMentalModelIds returns the ExcludeMentalModelIds field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *MentalModelTrigger) GetExcludeMentalModelIds() []string {
if o == nil {
var ret []string
return ret
}
return o.ExcludeMentalModelIds
}
// GetExcludeMentalModelIdsOk returns a tuple with the ExcludeMentalModelIds field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *MentalModelTrigger) GetExcludeMentalModelIdsOk() ([]string, bool) {
if o == nil || IsNil(o.ExcludeMentalModelIds) {
return nil, false
}
return o.ExcludeMentalModelIds, true
}
// HasExcludeMentalModelIds returns a boolean if a field has been set.
func (o *MentalModelTrigger) HasExcludeMentalModelIds() bool {
if o != nil && !IsNil(o.ExcludeMentalModelIds) {
return true
}
return false
}
// SetExcludeMentalModelIds gets a reference to the given []string and assigns it to the ExcludeMentalModelIds field.
func (o *MentalModelTrigger) SetExcludeMentalModelIds(v []string) {
o.ExcludeMentalModelIds = v
}
func (o MentalModelTrigger) MarshalJSON() ([]byte, error) { func (o MentalModelTrigger) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap() toSerialize,err := o.ToMap()
if err != nil { if err != nil {
@ -89,6 +195,15 @@ func (o MentalModelTrigger) ToMap() (map[string]interface{}, error) {
if !IsNil(o.RefreshAfterConsolidation) { if !IsNil(o.RefreshAfterConsolidation) {
toSerialize["refresh_after_consolidation"] = o.RefreshAfterConsolidation toSerialize["refresh_after_consolidation"] = o.RefreshAfterConsolidation
} }
if o.FactTypes != nil {
toSerialize["fact_types"] = o.FactTypes
}
if !IsNil(o.ExcludeMentalModels) {
toSerialize["exclude_mental_models"] = o.ExcludeMentalModels
}
if o.ExcludeMentalModelIds != nil {
toSerialize["exclude_mental_model_ids"] = o.ExcludeMentalModelIds
}
return toSerialize, nil return toSerialize, nil
} }

View file

@ -33,6 +33,10 @@ type ReflectRequest struct {
// How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). // How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).
TagsMatch *string `json:"tags_match,omitempty"` TagsMatch *string `json:"tags_match,omitempty"`
TagGroups []RecallRequestTagGroupsInner `json:"tag_groups,omitempty"` TagGroups []RecallRequestTagGroupsInner `json:"tag_groups,omitempty"`
FactTypes []string `json:"fact_types,omitempty"`
// If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
ExcludeMentalModels *bool `json:"exclude_mental_models,omitempty"`
ExcludeMentalModelIds []string `json:"exclude_mental_model_ids,omitempty"`
} }
type _ReflectRequest ReflectRequest type _ReflectRequest ReflectRequest
@ -48,6 +52,8 @@ func NewReflectRequest(query string) *ReflectRequest {
this.MaxTokens = &maxTokens this.MaxTokens = &maxTokens
var tagsMatch string = "any" var tagsMatch string = "any"
this.TagsMatch = &tagsMatch this.TagsMatch = &tagsMatch
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this return &this
} }
@ -60,6 +66,8 @@ func NewReflectRequestWithDefaults() *ReflectRequest {
this.MaxTokens = &maxTokens this.MaxTokens = &maxTokens
var tagsMatch string = "any" var tagsMatch string = "any"
this.TagsMatch = &tagsMatch this.TagsMatch = &tagsMatch
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this return &this
} }
@ -356,6 +364,104 @@ func (o *ReflectRequest) SetTagGroups(v []RecallRequestTagGroupsInner) {
o.TagGroups = v o.TagGroups = v
} }
// GetFactTypes returns the FactTypes field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *ReflectRequest) GetFactTypes() []string {
if o == nil {
var ret []string
return ret
}
return o.FactTypes
}
// GetFactTypesOk returns a tuple with the FactTypes field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *ReflectRequest) GetFactTypesOk() ([]string, bool) {
if o == nil || IsNil(o.FactTypes) {
return nil, false
}
return o.FactTypes, true
}
// HasFactTypes returns a boolean if a field has been set.
func (o *ReflectRequest) HasFactTypes() bool {
if o != nil && !IsNil(o.FactTypes) {
return true
}
return false
}
// SetFactTypes gets a reference to the given []string and assigns it to the FactTypes field.
func (o *ReflectRequest) SetFactTypes(v []string) {
o.FactTypes = v
}
// GetExcludeMentalModels returns the ExcludeMentalModels field value if set, zero value otherwise.
func (o *ReflectRequest) GetExcludeMentalModels() bool {
if o == nil || IsNil(o.ExcludeMentalModels) {
var ret bool
return ret
}
return *o.ExcludeMentalModels
}
// GetExcludeMentalModelsOk returns a tuple with the ExcludeMentalModels field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *ReflectRequest) GetExcludeMentalModelsOk() (*bool, bool) {
if o == nil || IsNil(o.ExcludeMentalModels) {
return nil, false
}
return o.ExcludeMentalModels, true
}
// HasExcludeMentalModels returns a boolean if a field has been set.
func (o *ReflectRequest) HasExcludeMentalModels() bool {
if o != nil && !IsNil(o.ExcludeMentalModels) {
return true
}
return false
}
// SetExcludeMentalModels gets a reference to the given bool and assigns it to the ExcludeMentalModels field.
func (o *ReflectRequest) SetExcludeMentalModels(v bool) {
o.ExcludeMentalModels = &v
}
// GetExcludeMentalModelIds returns the ExcludeMentalModelIds field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *ReflectRequest) GetExcludeMentalModelIds() []string {
if o == nil {
var ret []string
return ret
}
return o.ExcludeMentalModelIds
}
// GetExcludeMentalModelIdsOk returns a tuple with the ExcludeMentalModelIds field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *ReflectRequest) GetExcludeMentalModelIdsOk() ([]string, bool) {
if o == nil || IsNil(o.ExcludeMentalModelIds) {
return nil, false
}
return o.ExcludeMentalModelIds, true
}
// HasExcludeMentalModelIds returns a boolean if a field has been set.
func (o *ReflectRequest) HasExcludeMentalModelIds() bool {
if o != nil && !IsNil(o.ExcludeMentalModelIds) {
return true
}
return false
}
// SetExcludeMentalModelIds gets a reference to the given []string and assigns it to the ExcludeMentalModelIds field.
func (o *ReflectRequest) SetExcludeMentalModelIds(v []string) {
o.ExcludeMentalModelIds = v
}
func (o ReflectRequest) MarshalJSON() ([]byte, error) { func (o ReflectRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap() toSerialize,err := o.ToMap()
if err != nil { if err != nil {
@ -391,6 +497,15 @@ func (o ReflectRequest) ToMap() (map[string]interface{}, error) {
if o.TagGroups != nil { if o.TagGroups != nil {
toSerialize["tag_groups"] = o.TagGroups toSerialize["tag_groups"] = o.TagGroups
} }
if o.FactTypes != nil {
toSerialize["fact_types"] = o.FactTypes
}
if !IsNil(o.ExcludeMentalModels) {
toSerialize["exclude_mental_models"] = o.ExcludeMentalModels
}
if o.ExcludeMentalModelIds != nil {
toSerialize["exclude_mental_model_ids"] = o.ExcludeMentalModelIds
}
return toSerialize, nil return toSerialize, nil
} }

View file

@ -17,7 +17,7 @@ import pprint
import re # noqa: F401 import re # noqa: F401
import json import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set from typing import Optional, Set
from typing_extensions import Self from typing_extensions import Self
@ -27,7 +27,21 @@ class MentalModelTrigger(BaseModel):
Trigger settings for a mental model. Trigger settings for a mental model.
""" # noqa: E501 """ # noqa: E501
refresh_after_consolidation: Optional[StrictBool] = Field(default=False, description="If true, refresh this mental model after observations consolidation (real-time mode)") refresh_after_consolidation: Optional[StrictBool] = Field(default=False, description="If true, refresh this mental model after observations consolidation (real-time mode)")
__properties: ClassVar[List[str]] = ["refresh_after_consolidation"] fact_types: Optional[List[StrictStr]] = None
exclude_mental_models: Optional[StrictBool] = Field(default=False, description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).")
exclude_mental_model_ids: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["refresh_after_consolidation", "fact_types", "exclude_mental_models", "exclude_mental_model_ids"]
@field_validator('fact_types')
def fact_types_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
return value
for i in value:
if i not in set(['world', 'experience', 'observation']):
raise ValueError("each list item must be one of ('world', 'experience', 'observation')")
return value
model_config = ConfigDict( model_config = ConfigDict(
populate_by_name=True, populate_by_name=True,
@ -68,6 +82,16 @@ class MentalModelTrigger(BaseModel):
exclude=excluded_fields, exclude=excluded_fields,
exclude_none=True, exclude_none=True,
) )
# set to None if fact_types (nullable) is None
# and model_fields_set contains the field
if self.fact_types is None and "fact_types" in self.model_fields_set:
_dict['fact_types'] = None
# set to None if exclude_mental_model_ids (nullable) is None
# and model_fields_set contains the field
if self.exclude_mental_model_ids is None and "exclude_mental_model_ids" in self.model_fields_set:
_dict['exclude_mental_model_ids'] = None
return _dict return _dict
@classmethod @classmethod
@ -80,7 +104,10 @@ class MentalModelTrigger(BaseModel):
return cls.model_validate(obj) return cls.model_validate(obj)
_obj = cls.model_validate({ _obj = cls.model_validate({
"refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False "refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False,
"fact_types": obj.get("fact_types"),
"exclude_mental_models": obj.get("exclude_mental_models") if obj.get("exclude_mental_models") is not None else False,
"exclude_mental_model_ids": obj.get("exclude_mental_model_ids")
}) })
return _obj return _obj

View file

@ -17,7 +17,7 @@ import pprint
import re # noqa: F401 import re # noqa: F401
import json import json
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.budget import Budget from hindsight_client_api.models.budget import Budget
from hindsight_client_api.models.recall_request_tag_groups_inner import RecallRequestTagGroupsInner from hindsight_client_api.models.recall_request_tag_groups_inner import RecallRequestTagGroupsInner
@ -38,7 +38,10 @@ class ReflectRequest(BaseModel):
tags: Optional[List[StrictStr]] = None tags: Optional[List[StrictStr]] = None
tags_match: Optional[StrictStr] = Field(default='any', description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).") tags_match: Optional[StrictStr] = Field(default='any', description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).")
tag_groups: Optional[List[RecallRequestTagGroupsInner]] = None tag_groups: Optional[List[RecallRequestTagGroupsInner]] = None
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema", "tags", "tags_match", "tag_groups"] fact_types: Optional[List[StrictStr]] = None
exclude_mental_models: Optional[StrictBool] = Field(default=False, description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).")
exclude_mental_model_ids: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema", "tags", "tags_match", "tag_groups", "fact_types", "exclude_mental_models", "exclude_mental_model_ids"]
@field_validator('tags_match') @field_validator('tags_match')
def tags_match_validate_enum(cls, value): def tags_match_validate_enum(cls, value):
@ -50,6 +53,17 @@ class ReflectRequest(BaseModel):
raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')")
return value return value
@field_validator('fact_types')
def fact_types_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
return value
for i in value:
if i not in set(['world', 'experience', 'observation']):
raise ValueError("each list item must be one of ('world', 'experience', 'observation')")
return value
model_config = ConfigDict( model_config = ConfigDict(
populate_by_name=True, populate_by_name=True,
validate_assignment=True, validate_assignment=True,
@ -119,6 +133,16 @@ class ReflectRequest(BaseModel):
if self.tag_groups is None and "tag_groups" in self.model_fields_set: if self.tag_groups is None and "tag_groups" in self.model_fields_set:
_dict['tag_groups'] = None _dict['tag_groups'] = None
# set to None if fact_types (nullable) is None
# and model_fields_set contains the field
if self.fact_types is None and "fact_types" in self.model_fields_set:
_dict['fact_types'] = None
# set to None if exclude_mental_model_ids (nullable) is None
# and model_fields_set contains the field
if self.exclude_mental_model_ids is None and "exclude_mental_model_ids" in self.model_fields_set:
_dict['exclude_mental_model_ids'] = None
return _dict return _dict
@classmethod @classmethod
@ -139,7 +163,10 @@ class ReflectRequest(BaseModel):
"response_schema": obj.get("response_schema"), "response_schema": obj.get("response_schema"),
"tags": obj.get("tags"), "tags": obj.get("tags"),
"tags_match": obj.get("tags_match") if obj.get("tags_match") is not None else 'any', "tags_match": obj.get("tags_match") if obj.get("tags_match") is not None else 'any',
"tag_groups": [RecallRequestTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None "tag_groups": [RecallRequestTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None,
"fact_types": obj.get("fact_types"),
"exclude_mental_models": obj.get("exclude_mental_models") if obj.get("exclude_mental_models") is not None else False,
"exclude_mental_model_ids": obj.get("exclude_mental_model_ids")
}) })
return _obj return _obj

View file

@ -1331,6 +1331,24 @@ export type MentalModelTrigger = {
* If true, refresh this mental model after observations consolidation (real-time mode) * If true, refresh this mental model after observations consolidation (real-time mode)
*/ */
refresh_after_consolidation?: boolean; refresh_after_consolidation?: boolean;
/**
* Fact Types
*
* Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).
*/
fact_types?: Array<"world" | "experience" | "observation"> | null;
/**
* Exclude Mental Models
*
* If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
*/
exclude_mental_models?: boolean;
/**
* Exclude Mental Model Ids
*
* Exclude specific mental models by ID from the reflect loop.
*/
exclude_mental_model_ids?: Array<string> | null;
}; };
/** /**
@ -1825,6 +1843,24 @@ export type ReflectRequest = {
tag_groups?: Array< tag_groups?: Array<
TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot
> | null; > | null;
/**
* Fact Types
*
* Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).
*/
fact_types?: Array<"world" | "experience" | "observation"> | null;
/**
* Exclude Mental Models
*
* If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
*/
exclude_mental_models?: boolean;
/**
* Exclude Mental Model Ids
*
* Exclude specific mental models by ID from the reflect loop.
*/
exclude_mental_model_ids?: Array<string> | null;
}; };
/** /**

View file

@ -14,6 +14,9 @@ export async function POST(request: NextRequest) {
tags, tags,
tags_match, tags_match,
max_tokens, max_tokens,
fact_types,
exclude_mental_models,
exclude_mental_model_ids,
} = body; } = body;
const requestBody: any = { const requestBody: any = {
@ -22,6 +25,9 @@ export async function POST(request: NextRequest) {
tags, tags,
tags_match, tags_match,
max_tokens: max_tokens || undefined, max_tokens: max_tokens || undefined,
fact_types: fact_types || undefined,
exclude_mental_models: exclude_mental_models || undefined,
exclude_mental_model_ids: exclude_mental_model_ids || undefined,
}; };
// Add include options if specified // Add include options if specified

View file

@ -0,0 +1,113 @@
"use client";
import { cn } from "@/lib/utils";
export type FactType = "world" | "experience" | "observation";
export const ALL_FACT_TYPES: FactType[] = ["world", "experience", "observation"];
const FACT_TYPE_CONFIG: Record<
FactType,
{ label: string; active: string; inactive: string; dot: string }
> = {
world: {
label: "World",
active: "bg-blue-500/15 text-blue-700 border-blue-400 dark:text-blue-300 dark:border-blue-500",
inactive:
"border-border text-muted-foreground hover:border-blue-300 hover:text-blue-600 dark:hover:text-blue-400",
dot: "bg-blue-500",
},
experience: {
label: "Experience",
active:
"bg-emerald-500/15 text-emerald-700 border-emerald-400 dark:text-emerald-300 dark:border-emerald-500",
inactive:
"border-border text-muted-foreground hover:border-emerald-300 hover:text-emerald-600 dark:hover:text-emerald-400",
dot: "bg-emerald-500",
},
observation: {
label: "Observation",
active:
"bg-amber-500/15 text-amber-700 border-amber-400 dark:text-amber-300 dark:border-amber-500",
inactive:
"border-border text-muted-foreground hover:border-amber-300 hover:text-amber-600 dark:hover:text-amber-400",
dot: "bg-amber-500",
},
};
function FactTypePill({
ft,
active,
onToggle,
}: {
ft: FactType;
active: boolean;
onToggle: () => void;
}) {
const cfg = FACT_TYPE_CONFIG[ft];
return (
<button
type="button"
onClick={onToggle}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all",
active ? cfg.active : cfg.inactive
)}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", active ? cfg.dot : "bg-muted-foreground/50")}
/>
{cfg.label}
</button>
);
}
/**
* Inline pill-toggle fact-type filter for filter bars.
* An empty selection means "all types included".
*/
export function FactTypeFilter({
value,
onChange,
label = "Fact types:",
}: {
value: FactType[];
onChange: (next: FactType[]) => void;
label?: string;
}) {
const toggle = (ft: FactType) =>
onChange(value.includes(ft) ? value.filter((f) => f !== ft) : [...value, ft]);
return (
<div className="flex items-center gap-2">
{label && <span className="text-sm font-medium text-muted-foreground">{label}</span>}
<div className="flex gap-1.5">
{ALL_FACT_TYPES.map((ft) => (
<FactTypePill key={ft} ft={ft} active={value.includes(ft)} onToggle={() => toggle(ft)} />
))}
</div>
</div>
);
}
/**
* Pill-toggle group for use inside forms/dialogs.
*/
export function FactTypeCheckboxGroup({
value,
onChange,
}: {
value: FactType[];
onChange: (next: FactType[]) => void;
}) {
const toggle = (ft: FactType) =>
onChange(value.includes(ft) ? value.filter((f) => f !== ft) : [...value, ft]);
return (
<div className="flex flex-wrap gap-1.5">
{ALL_FACT_TYPES.map((ft) => (
<FactTypePill key={ft} ft={ft} active={value.includes(ft)} onToggle={() => toggle(ft)} />
))}
</div>
);
}

View file

@ -8,6 +8,8 @@ import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FactType, FactTypeCheckboxGroup } from "@/components/fact-type-filter";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
@ -86,6 +88,9 @@ interface MentalModel {
max_tokens: number; max_tokens: number;
trigger: { trigger: {
refresh_after_consolidation: boolean; refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
}; };
last_refreshed_at: string; last_refreshed_at: string;
created_at: string; created_at: string;
@ -593,6 +598,9 @@ function CreateMentalModelDialog({
maxTokens: "2048", maxTokens: "2048",
tags: "", tags: "",
autoRefresh: false, autoRefresh: false,
factTypes: [] as Array<"world" | "experience" | "observation">,
excludeMentalModels: false,
excludeMentalModelIds: "",
}); });
const handleCreate = async () => { const handleCreate = async () => {
@ -608,13 +616,23 @@ function CreateMentalModelDialog({
const maxTokens = parseInt(form.maxTokens) || 2048; const maxTokens = parseInt(form.maxTokens) || 2048;
// Submit mental model creation - content will be generated in background // Submit mental model creation - content will be generated in background
const excludeIds = form.excludeMentalModelIds
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
await client.createMentalModel(currentBank, { await client.createMentalModel(currentBank, {
id: form.id.trim() || undefined, id: form.id.trim() || undefined,
name: form.name.trim(), name: form.name.trim(),
source_query: form.sourceQuery.trim(), source_query: form.sourceQuery.trim(),
tags: tags.length > 0 ? tags : undefined, tags: tags.length > 0 ? tags : undefined,
max_tokens: maxTokens, max_tokens: maxTokens,
trigger: { refresh_after_consolidation: form.autoRefresh }, trigger: {
refresh_after_consolidation: form.autoRefresh,
fact_types: form.factTypes.length > 0 ? form.factTypes : undefined,
exclude_mental_models: form.excludeMentalModels || undefined,
exclude_mental_model_ids: excludeIds.length > 0 ? excludeIds : undefined,
},
}); });
setForm({ setForm({
@ -624,6 +642,9 @@ function CreateMentalModelDialog({
maxTokens: "2048", maxTokens: "2048",
tags: "", tags: "",
autoRefresh: false, autoRefresh: false,
factTypes: [],
excludeMentalModels: false,
excludeMentalModelIds: "",
}); });
onCreated(); onCreated();
} catch (error) { } catch (error) {
@ -645,6 +666,9 @@ function CreateMentalModelDialog({
maxTokens: "2048", maxTokens: "2048",
tags: "", tags: "",
autoRefresh: false, autoRefresh: false,
factTypes: [],
excludeMentalModels: false,
excludeMentalModelIds: "",
}); });
onClose(); onClose();
} }
@ -659,80 +683,111 @@ function CreateMentalModelDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <Tabs defaultValue="general" className="py-2">
<div className="space-y-2"> <TabsList className="w-full">
<label className="text-sm font-medium text-foreground"> <TabsTrigger value="general" className="flex-1">
ID <span className="text-muted-foreground font-normal">(optional)</span> General
</label> </TabsTrigger>
<Input <TabsTrigger value="options" className="flex-1">
value={form.id} Options
onChange={(e) => setForm({ ...form, id: e.target.value })} </TabsTrigger>
placeholder="e.g., team-communication" </TabsList>
/>
<p className="text-xs text-muted-foreground"> <TabsContent value="general" className="space-y-4 pt-4">
Custom ID for the mental model. If not provided, a UUID will be generated. <div className="space-y-2">
</p> <label className="text-sm font-medium text-foreground">ID</label>
</div> <Input
<div className="space-y-2"> value={form.id}
<label className="text-sm font-medium text-foreground">Name *</label> onChange={(e) => setForm({ ...form, id: e.target.value })}
<Input placeholder="e.g., team-communication"
value={form.name} />
onChange={(e) => setForm({ ...form, name: e.target.value })} </div>
placeholder="e.g., Team Communication Preferences" <div className="space-y-2">
/> <label className="text-sm font-medium text-foreground">Name *</label>
</div> <Input
<div className="space-y-2"> value={form.name}
<label className="text-sm font-medium text-foreground">Source Query *</label> onChange={(e) => setForm({ ...form, name: e.target.value })}
<Input placeholder="e.g., Team Communication Preferences"
value={form.sourceQuery} />
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })} </div>
placeholder="e.g., How does the team prefer to communicate?" <div className="space-y-2">
/> <label className="text-sm font-medium text-foreground">Source Query *</label>
<p className="text-xs text-muted-foreground"> <Input
This query will be run to generate the initial content, and re-run when you refresh. value={form.sourceQuery}
</p> onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
</div> placeholder="e.g., How does the team prefer to communicate?"
<div className="space-y-2"> />
<label className="text-sm font-medium text-foreground">Max Tokens</label> </div>
<Input <div className="space-y-2">
type="number" <label className="text-sm font-medium text-foreground">Max Tokens</label>
value={form.maxTokens} <Input
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })} type="number"
placeholder="2048" value={form.maxTokens}
min="256" onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
max="8192" placeholder="2048"
/> min="256"
<p className="text-xs text-muted-foreground"> max="8192"
Maximum tokens for the generated response (256-8192). />
</p> </div>
</div> </TabsContent>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground"> <TabsContent value="options" className="space-y-4 pt-4">
Tags <span className="text-muted-foreground font-normal">(optional)</span> <div className="space-y-2">
</label> <label className="text-sm font-medium text-foreground">Tags</label>
<Input <Input
value={form.tags} value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })} onChange={(e) => setForm({ ...form, tags: e.target.value })}
placeholder="e.g., project-x, team-alpha (comma-separated)" placeholder="e.g., project-x, team-alpha (comma-separated)"
/> />
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="auto-refresh" id="auto-refresh"
checked={form.autoRefresh} checked={form.autoRefresh}
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })} onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
/> />
<label <label
htmlFor="auto-refresh" htmlFor="auto-refresh"
className="text-sm font-medium text-foreground cursor-pointer" className="text-sm font-medium text-foreground cursor-pointer"
> >
Auto-refresh after consolidation Auto-refresh after consolidation
</label> </label>
</div> </div>
<p className="text-xs text-muted-foreground -mt-2 ml-6"> <div className="space-y-3">
Automatically refresh this mental model when memories are consolidated. <label className="text-sm font-medium text-foreground">Fact Types</label>
</p> <FactTypeCheckboxGroup
</div> value={form.factTypes}
onChange={(v) => setForm({ ...form, factTypes: v as FactType[] })}
/>
<p className="text-xs text-muted-foreground">Leave empty to include all types.</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="exclude-mental-models"
checked={form.excludeMentalModels}
onCheckedChange={(checked) =>
setForm({ ...form, excludeMentalModels: checked === true })
}
/>
<label
htmlFor="exclude-mental-models"
className="text-sm font-medium text-foreground cursor-pointer"
>
Exclude all mental models
</label>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Exclude Mental Model IDs
</label>
<Input
value={form.excludeMentalModelIds}
onChange={(e) => setForm({ ...form, excludeMentalModelIds: e.target.value })}
placeholder="e.g., model-a, model-b (comma-separated)"
/>
</div>
</TabsContent>
</Tabs>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={creating}> <Button variant="outline" onClick={onClose} disabled={creating}>
@ -776,6 +831,12 @@ function UpdateMentalModelDialog({
maxTokens: String(mentalModel.max_tokens || 2048), maxTokens: String(mentalModel.max_tokens || 2048),
tags: mentalModel.tags.join(", "), tags: mentalModel.tags.join(", "),
autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false, autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false,
factTypes:
(mentalModel.trigger?.fact_types as
| Array<"world" | "experience" | "observation">
| undefined) || [],
excludeMentalModels: mentalModel.trigger?.exclude_mental_models || false,
excludeMentalModelIds: (mentalModel.trigger?.exclude_mental_model_ids || []).join(", "),
}); });
// Reset form when mental model changes or dialog opens // Reset form when mental model changes or dialog opens
@ -787,6 +848,12 @@ function UpdateMentalModelDialog({
maxTokens: String(mentalModel.max_tokens || 2048), maxTokens: String(mentalModel.max_tokens || 2048),
tags: mentalModel.tags.join(", "), tags: mentalModel.tags.join(", "),
autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false, autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false,
factTypes:
(mentalModel.trigger?.fact_types as
| Array<"world" | "experience" | "observation">
| undefined) || [],
excludeMentalModels: mentalModel.trigger?.exclude_mental_models || false,
excludeMentalModelIds: (mentalModel.trigger?.exclude_mental_model_ids || []).join(", "),
}); });
} }
}, [open, mentalModel]); }, [open, mentalModel]);
@ -803,12 +870,22 @@ function UpdateMentalModelDialog({
const maxTokens = parseInt(form.maxTokens) || 2048; const maxTokens = parseInt(form.maxTokens) || 2048;
const excludeIds = form.excludeMentalModelIds
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const updated = await client.updateMentalModel(currentBank, mentalModel.id, { const updated = await client.updateMentalModel(currentBank, mentalModel.id, {
name: form.name.trim(), name: form.name.trim(),
source_query: form.sourceQuery.trim(), source_query: form.sourceQuery.trim(),
tags: tags.length > 0 ? tags : undefined, tags: tags.length > 0 ? tags : undefined,
max_tokens: maxTokens, max_tokens: maxTokens,
trigger: { refresh_after_consolidation: form.autoRefresh }, trigger: {
refresh_after_consolidation: form.autoRefresh,
fact_types: form.factTypes.length > 0 ? form.factTypes : undefined,
exclude_mental_models: form.excludeMentalModels || undefined,
exclude_mental_model_ids: excludeIds.length > 0 ? excludeIds : undefined,
},
}); });
onUpdated(updated); onUpdated(updated);
@ -830,72 +907,107 @@ function UpdateMentalModelDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <Tabs defaultValue="general" className="py-2">
<div className="space-y-2"> <TabsList className="w-full">
<label className="text-sm font-medium text-muted-foreground">ID</label> <TabsTrigger value="general" className="flex-1">
<Input value={mentalModel.id} disabled className="bg-muted" /> General
<p className="text-xs text-muted-foreground">ID cannot be changed after creation.</p> </TabsTrigger>
</div> <TabsTrigger value="options" className="flex-1">
<div className="space-y-2"> Options
<label className="text-sm font-medium text-foreground">Name *</label> </TabsTrigger>
<Input </TabsList>
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} <TabsContent value="general" className="space-y-4 pt-4">
placeholder="e.g., Team Communication Preferences" <div className="space-y-2">
/> <label className="text-sm font-medium text-muted-foreground">ID</label>
</div> <Input value={mentalModel.id} disabled className="bg-muted" />
<div className="space-y-2"> </div>
<label className="text-sm font-medium text-foreground">Source Query *</label> <div className="space-y-2">
<Input <label className="text-sm font-medium text-foreground">Name *</label>
value={form.sourceQuery} <Input
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })} value={form.name}
placeholder="e.g., How does the team prefer to communicate?" onChange={(e) => setForm({ ...form, name: e.target.value })}
/> placeholder="e.g., Team Communication Preferences"
<p className="text-xs text-muted-foreground"> />
This query will be run to generate the initial content, and re-run when you refresh. </div>
</p> <div className="space-y-2">
</div> <label className="text-sm font-medium text-foreground">Source Query *</label>
<div className="space-y-2"> <Input
<label className="text-sm font-medium text-foreground">Max Tokens</label> value={form.sourceQuery}
<Input onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
type="number" placeholder="e.g., How does the team prefer to communicate?"
value={form.maxTokens} />
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })} </div>
placeholder="2048" <div className="space-y-2">
min="256" <label className="text-sm font-medium text-foreground">Max Tokens</label>
max="8192" <Input
/> type="number"
<p className="text-xs text-muted-foreground"> value={form.maxTokens}
Maximum tokens for the generated response (256-8192). onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
</p> placeholder="2048"
</div> min="256"
<div className="space-y-2"> max="8192"
<label className="text-sm font-medium text-foreground"> />
Tags <span className="text-muted-foreground font-normal">(optional)</span> </div>
</label> </TabsContent>
<Input
value={form.tags} <TabsContent value="options" className="space-y-4 pt-4">
onChange={(e) => setForm({ ...form, tags: e.target.value })} <div className="space-y-2">
placeholder="e.g., project-x, team-alpha (comma-separated)" <label className="text-sm font-medium text-foreground">Tags</label>
/> <Input
</div> value={form.tags}
<div className="flex items-center space-x-2"> onChange={(e) => setForm({ ...form, tags: e.target.value })}
<Checkbox placeholder="e.g., project-x, team-alpha (comma-separated)"
id="update-auto-refresh" />
checked={form.autoRefresh} </div>
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })} <div className="flex items-center space-x-2">
/> <Checkbox
<label id="update-auto-refresh"
htmlFor="update-auto-refresh" checked={form.autoRefresh}
className="text-sm font-medium text-foreground cursor-pointer" onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
> />
Auto-refresh after consolidation <label
</label> htmlFor="update-auto-refresh"
</div> className="text-sm font-medium text-foreground cursor-pointer"
<p className="text-xs text-muted-foreground -mt-2 ml-6"> >
Automatically refresh this mental model when memories are consolidated. Auto-refresh after consolidation
</p> </label>
</div> </div>
<div className="space-y-3">
<label className="text-sm font-medium text-foreground">Fact Types</label>
<FactTypeCheckboxGroup
value={form.factTypes}
onChange={(v) => setForm({ ...form, factTypes: v as FactType[] })}
/>
<p className="text-xs text-muted-foreground">Leave empty to include all types.</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="update-exclude-mental-models"
checked={form.excludeMentalModels}
onCheckedChange={(checked) =>
setForm({ ...form, excludeMentalModels: checked === true })
}
/>
<label
htmlFor="update-exclude-mental-models"
className="text-sm font-medium text-foreground cursor-pointer"
>
Exclude all mental models
</label>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Exclude Mental Model IDs
</label>
<Input
value={form.excludeMentalModelIds}
onChange={(e) => setForm({ ...form, excludeMentalModelIds: e.target.value })}
placeholder="e.g., model-a, model-b (comma-separated)"
/>
</div>
</TabsContent>
</Tabs>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updating}> <Button variant="outline" onClick={onClose} disabled={updating}>

View file

@ -14,6 +14,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@ -33,7 +34,6 @@ import JsonView from "react18-json-view";
import "react18-json-view/src/style.css"; import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel"; import { MemoryDetailPanel } from "./memory-detail-panel";
type FactType = "world" | "experience" | "observation";
type Budget = "low" | "mid" | "high"; type Budget = "low" | "mid" | "high";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict"; type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "results" | "trace" | "json"; type ViewMode = "results" | "trace" | "json";
@ -157,10 +157,6 @@ export function SearchDebugView() {
} }
}; };
const toggleFactType = (ft: FactType) => {
setFactTypes((prev) => (prev.includes(ft) ? prev.filter((t) => t !== ft) : [...prev, ft]));
};
if (!currentBank) { if (!currentBank) {
return ( return (
<Card className="border-dashed"> <Card className="border-dashed">
@ -197,28 +193,7 @@ export function SearchDebugView() {
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t"> <div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
{/* Fact Types */} <FactTypeFilter value={factTypes} onChange={setFactTypes} label="Types:" />
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-muted-foreground">Types:</span>
<div className="flex gap-3">
{(["world", "experience"] as FactType[]).map((ft) => (
<label key={ft} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={factTypes.includes(ft)}
onCheckedChange={() => toggleFactType(ft)}
/>
<span className="text-sm capitalize">{ft}</span>
</label>
))}
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={factTypes.includes("observation")}
onCheckedChange={() => toggleFactType("observation")}
/>
<span className="text-sm">Observations</span>
</label>
</div>
</div>
<div className="h-6 w-px bg-border" /> <div className="h-6 w-px bg-border" />

View file

@ -13,6 +13,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Sparkles, Sparkles,
@ -51,6 +52,9 @@ export function ThinkView() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any"); const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any");
const [factTypes, setFactTypes] = useState<FactType[]>([]);
const [excludeMentalModels, setExcludeMentalModels] = useState(false);
const [excludeMentalModelIds, setExcludeMentalModelIds] = useState("");
const [feedback, setFeedback] = useState(""); const [feedback, setFeedback] = useState("");
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
@ -151,6 +155,11 @@ export function ThinkView() {
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t.length > 0); .filter((t) => t.length > 0);
const excludeIds = excludeMentalModelIds
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const data: any = await client.reflect({ const data: any = await client.reflect({
bank_id: currentBank, bank_id: currentBank,
query, query,
@ -159,6 +168,9 @@ export function ThinkView() {
include_facts: includeFacts, include_facts: includeFacts,
include_tool_calls: includeToolCalls, include_tool_calls: includeToolCalls,
...(parsedTags.length > 0 && { tags: parsedTags, tags_match: tagsMatch }), ...(parsedTags.length > 0 && { tags: parsedTags, tags_match: tagsMatch }),
...(factTypes.length > 0 && { fact_types: factTypes }),
...(excludeMentalModels && { exclude_mental_models: true }),
...(excludeIds.length > 0 && { exclude_mental_model_ids: excludeIds }),
}); });
setResult(data); setResult(data);
} catch (error) { } catch (error) {
@ -275,6 +287,29 @@ export function ThinkView() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Fact Types & Mental Model Filters */}
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
<FactTypeFilter value={factTypes} onChange={setFactTypes} />
<div className="h-6 w-px bg-border" />
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={excludeMentalModels}
onCheckedChange={(c) => setExcludeMentalModels(c as boolean)}
/>
<span className="text-sm">Exclude mental models</span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Exclude IDs:</span>
<Input
type="text"
value={excludeMentalModelIds}
onChange={(e) => setExcludeMentalModelIds(e.target.value)}
placeholder="model-a, model-b"
className="h-8 w-48"
/>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -47,7 +47,12 @@ export interface MentalModel {
content: string; content: string;
tags: string[]; tags: string[];
max_tokens: number; max_tokens: number;
trigger: { refresh_after_consolidation: boolean }; trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string; last_refreshed_at: string;
created_at: string; created_at: string;
reflect_response?: any; reflect_response?: any;
@ -183,6 +188,9 @@ export class ControlPlaneClient {
include_tool_calls?: boolean; include_tool_calls?: boolean;
tags?: string[]; tags?: string[];
tags_match?: "any" | "all" | "any_strict" | "all_strict"; tags_match?: "any" | "all" | "any_strict" | "all_strict";
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
}) { }) {
return this.fetchApi("/api/reflect", { return this.fetchApi("/api/reflect", {
method: "POST", method: "POST",
@ -757,7 +765,12 @@ export class ControlPlaneClient {
content: string; content: string;
tags: string[]; tags: string[];
max_tokens: number; max_tokens: number;
trigger: { refresh_after_consolidation: boolean }; trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string; last_refreshed_at: string;
created_at: string; created_at: string;
reflect_response?: { reflect_response?: {
@ -780,7 +793,12 @@ export class ControlPlaneClient {
source_query: string; source_query: string;
tags?: string[]; tags?: string[];
max_tokens?: number; max_tokens?: number;
trigger?: { refresh_after_consolidation: boolean }; trigger?: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
} }
) { ) {
return this.fetchApi<{ return this.fetchApi<{
@ -809,7 +827,12 @@ export class ControlPlaneClient {
source_query?: string; source_query?: string;
max_tokens?: number; max_tokens?: number;
tags?: string[]; tags?: string[];
trigger?: { refresh_after_consolidation: boolean }; trigger?: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
} }
) { ) {
return this.fetchApi<{ return this.fetchApi<{
@ -820,7 +843,12 @@ export class ControlPlaneClient {
content: string; content: string;
tags: string[]; tags: string[];
max_tokens: number; max_tokens: number;
trigger: { refresh_after_consolidation: boolean }; trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string; last_refreshed_at: string;
created_at: string; created_at: string;
reflect_response?: { reflect_response?: {

View file

@ -6299,6 +6299,47 @@
"title": "Refresh After Consolidation", "title": "Refresh After Consolidation",
"description": "If true, refresh this mental model after observations consolidation (real-time mode)", "description": "If true, refresh this mental model after observations consolidation (real-time mode)",
"default": false "default": false
},
"fact_types": {
"anyOf": [
{
"items": {
"type": "string",
"enum": [
"world",
"experience",
"observation"
]
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Fact Types",
"description": "Filter which fact types are retrieved during reflect. None means all types (world, experience, observation)."
},
"exclude_mental_models": {
"type": "boolean",
"title": "Exclude Mental Models",
"description": "If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
"default": false
},
"exclude_mental_model_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Exclude Mental Model Ids",
"description": "Exclude specific mental models by ID from the reflect loop."
} }
}, },
"type": "object", "type": "object",
@ -7299,6 +7340,47 @@
], ],
"title": "Tag Groups", "title": "Tag Groups",
"description": "Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}." "description": "Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}."
},
"fact_types": {
"anyOf": [
{
"items": {
"type": "string",
"enum": [
"world",
"experience",
"observation"
]
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Fact Types",
"description": "Filter which fact types are retrieved during reflect. None means all types (world, experience, observation)."
},
"exclude_mental_models": {
"type": "boolean",
"title": "Exclude Mental Models",
"description": "If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
"default": false
},
"exclude_mental_model_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Exclude Mental Model Ids",
"description": "Exclude specific mental models by ID from the reflect loop."
} }
}, },
"type": "object", "type": "object",

View file

@ -6299,6 +6299,47 @@
"title": "Refresh After Consolidation", "title": "Refresh After Consolidation",
"description": "If true, refresh this mental model after observations consolidation (real-time mode)", "description": "If true, refresh this mental model after observations consolidation (real-time mode)",
"default": false "default": false
},
"fact_types": {
"anyOf": [
{
"items": {
"type": "string",
"enum": [
"world",
"experience",
"observation"
]
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Fact Types",
"description": "Filter which fact types are retrieved during reflect. None means all types (world, experience, observation)."
},
"exclude_mental_models": {
"type": "boolean",
"title": "Exclude Mental Models",
"description": "If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
"default": false
},
"exclude_mental_model_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Exclude Mental Model Ids",
"description": "Exclude specific mental models by ID from the reflect loop."
} }
}, },
"type": "object", "type": "object",
@ -7299,6 +7340,47 @@
], ],
"title": "Tag Groups", "title": "Tag Groups",
"description": "Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}." "description": "Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}."
},
"fact_types": {
"anyOf": [
{
"items": {
"type": "string",
"enum": [
"world",
"experience",
"observation"
]
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Fact Types",
"description": "Filter which fact types are retrieved during reflect. None means all types (world, experience, observation)."
},
"exclude_mental_models": {
"type": "boolean",
"title": "Exclude Mental Models",
"description": "If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
"default": false
},
"exclude_mental_model_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Exclude Mental Model Ids",
"description": "Exclude specific mental models by ID from the reflect loop."
} }
}, },
"type": "object", "type": "object",