diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index a32dcad2..4580ebd5 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -669,6 +669,25 @@ class ReflectRequest(BaseModel): 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: 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") def validate_tags_exclusive(self) -> "ReflectRequest": @@ -1435,6 +1454,25 @@ class MentalModelTrigger(BaseModel): default=False, 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): @@ -2505,6 +2543,9 @@ def _register_routes(app: FastAPI): tags=request.tags, tags_match=request.tags_match, 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 diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 32f96853..6fa31cc5 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -875,14 +875,23 @@ class MemoryEngine(MemoryEngineInterface): tags = mental_model.get("tags") 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 + # Always add self to excluded IDs to prevent circular reference reflect_result = await self.reflect_async( bank_id=bank_id, query=source_query, request_context=internal_context, tags=tags, 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" @@ -5120,6 +5129,8 @@ class MemoryEngine(MemoryEngineInterface): tags_match: TagsMatch = "any", tag_groups: list[TagGroup] | 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, ) -> ReflectResult: """ @@ -5240,6 +5251,11 @@ class MemoryEngine(MemoryEngineInterface): 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]: return await tool_recall( self, @@ -5251,6 +5267,7 @@ class MemoryEngine(MemoryEngineInterface): tags_match=tags_match, tag_groups=tag_groups, 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]: @@ -5273,15 +5290,17 @@ class MemoryEngine(MemoryEngineInterface): if directives: logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives") - # Check if the bank has any mental models - async with pool.acquire() as conn: - mental_model_count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1", - bank_id, - ) - has_mental_models = mental_model_count > 0 - if has_mental_models: - logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models") + # Check if the bank has any mental models (skip check if all mental models are excluded) + has_mental_models = False + if not exclude_mental_models: + async with pool.acquire() as conn: + mental_model_count = await conn.fetchval( + f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1", + bank_id, + ) + 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) if not _skip_span: @@ -5306,6 +5325,8 @@ class MemoryEngine(MemoryEngineInterface): response_schema=response_schema, directives=directives, has_mental_models=has_mental_models, + include_observations=include_observations, + include_recall=include_recall, budget=effective_budget, max_context_tokens=max_context_tokens, ) @@ -6437,6 +6458,12 @@ class MemoryEngine(MemoryEngineInterface): tags = mental_model.get("tags") 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 # Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh" reflect_result = await self.reflect_async( @@ -6445,7 +6472,9 @@ class MemoryEngine(MemoryEngineInterface): request_context=request_context, tags=tags, 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, ) diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/agent.py b/hindsight-api-slim/hindsight_api/engine/reflect/agent.py index 5d700b5e..1a16622d 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/agent.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/agent.py @@ -316,6 +316,8 @@ async def run_reflect_agent( response_schema: dict | None = None, directives: list[dict[str, Any]] | None = None, has_mental_models: bool = False, + include_observations: bool = True, + include_recall: bool = True, budget: str | None = None, max_context_tokens: int = 100_000, ) -> ReflectAgentResult: @@ -355,7 +357,14 @@ async def run_reflect_agent( directive_rules = _extract_directive_rules(directives) if directives else None # Get tools for this agent (with directive compliance field if directives exist) - tools = get_reflect_tools(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) system_prompt = build_system_prompt_for_tools( @@ -538,19 +547,18 @@ async def run_reflect_agent( llm_start = time.time() # Determine tool_choice for this iteration. - # Force the full hierarchical retrieval path before allowing auto: - # With mental models: - # 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto - # Without mental models: - # 0 → search_observations, 1 → recall, 2+ → auto - if iteration == 0 and has_mental_models: - iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}} - elif iteration == 0: - iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}} - elif iteration == 1 and has_mental_models: - iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}} - elif iteration == 1 or (iteration == 2 and has_mental_models): - iter_tool_choice = {"type": "function", "function": {"name": "recall"}} + # Force the full hierarchical retrieval path (only for enabled tools) before allowing auto. + # Build the forced sequence from the tools that are actually enabled. + forced_sequence = [] + if has_mental_models: + forced_sequence.append("search_mental_models") + if include_observations: + forced_sequence.append("search_observations") + if include_recall: + forced_sequence.append("recall") + + if iteration < len(forced_sequence): + iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}} else: 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) other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)] if other_tools: - # Add assistant message with tool calls + # 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( { "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 tool_tasks = [ _execute_tool_with_timing( @@ -785,6 +820,7 @@ async def run_reflect_agent( search_observations_fn, recall_fn, expand_fn, + enabled_tools=enabled_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]]], recall_fn: Callable[[str, int, int], 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]: """Execute a tool call and return result with timing.""" from hindsight_api.tracing import get_tracer @@ -1007,6 +1044,7 @@ async def _execute_tool_with_timing( search_observations_fn, recall_fn, expand_fn, + enabled_tools=enabled_tools, ) # Set success attributes @@ -1046,11 +1084,16 @@ async def _execute_tool( search_observations_fn: Callable[[str, 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]]], + enabled_tools: frozenset[str] | None = None, ) -> dict[str, Any]: """Execute a single tool by name.""" # Normalize tool name for various LLM output formats 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": query = args.get("query") if not query: diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/tools.py b/hindsight-api-slim/hindsight_api/engine/reflect/tools.py index a55d9833..6ef73a18 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/tools.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/tools.py @@ -200,6 +200,7 @@ async def tool_recall( tag_groups: "list | None" = None, connection_budget: int = 1, max_chunk_tokens: int = 1000, + fact_types: list[str] | None = None, ) -> dict[str, Any]: """ 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" 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) + fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"]. Returns: 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 result = await memory_engine.recall_async( bank_id=bank_id, query=query, - fact_type=["experience", "world"], + fact_type=recall_fact_type, max_tokens=max_tokens, enable_trace=False, request_context=request_context, diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py b/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py index 15c14fe1..d9bb5a94 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py @@ -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. @@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]: Args: directive_rules: Optional list of directive rule strings. If provided, 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: List of tool definitions in OpenAI format """ - tools = [ - TOOL_SEARCH_MENTAL_MODELS, - TOOL_SEARCH_OBSERVATIONS, - TOOL_RECALL, - TOOL_EXPAND, - ] + tools = [] + + if include_mental_models: + tools.append(TOOL_SEARCH_MENTAL_MODELS) + 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 if directive_rules: diff --git a/hindsight-api-slim/tests/conftest.py b/hindsight-api-slim/tests/conftest.py index 2a5822fe..8d0c64a8 100644 --- a/hindsight-api-slim/tests/conftest.py +++ b/hindsight-api-slim/tests/conftest.py @@ -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, 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. 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 processes that share the same pg0 instance. pg0 will persist for the next test run. """ - if db_url: - # Use provided database URL directly + from hindsight_api.pg0 import parse_pg0_url as _parse_pg0_url + + # 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 + 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 if worker_id == "master": # 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 # Use a lock file to ensure only one worker starts pg0 - lock_file = root_tmp_dir / "pg0_setup.lock" - url_file = root_tmp_dir / "pg0_url.txt" + lock_file = root_tmp_dir / f"pg0_setup_{pg0_instance_name}.lock" + url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt" with filelock.FileLock(str(lock_file)): 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() else: # 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 loop = asyncio.new_event_loop() diff --git a/hindsight-api-slim/tests/test_reflections.py b/hindsight-api-slim/tests/test_reflections.py index 3ff2f4fe..b87183e2 100644 --- a/hindsight-api-slim/tests/test_reflections.py +++ b/hindsight-api-slim/tests/test_reflections.py @@ -485,3 +485,206 @@ class TestReflectUsesMentalModels: # Cleanup 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 diff --git a/hindsight-cli/src/commands/explore.rs b/hindsight-cli/src/commands/explore.rs index e463f7d7..1ef24b2b 100644 --- a/hindsight-cli/src/commands/explore.rs +++ b/hindsight-cli/src/commands/explore.rs @@ -363,6 +363,9 @@ impl App { tags: None, tags_match: TagsMatch::Any, tag_groups: None, + fact_types: None, + exclude_mental_models: false, + exclude_mental_model_ids: None, }; let result = client.reflect(&bank_id, &request, false) diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index 9a81b587..ac2c7b60 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -366,6 +366,9 @@ pub fn reflect( tags: if tags.is_empty() { None } else { Some(tags) }, tags_match: parse_tags_match(&tags_match), tag_groups: None, + fact_types: None, + exclude_mental_models: false, + exclude_mental_model_ids: None, }; let response = client.reflect(agent_id, &request, verbose); diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index b1327258..a8e4b32d 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -4074,6 +4074,13 @@ components: id: id trigger: 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 content: content tags: @@ -4089,6 +4096,13 @@ components: id: id trigger: 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 content: content tags: @@ -4115,6 +4129,13 @@ components: id: id trigger: 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 content: content tags: @@ -4169,6 +4190,13 @@ components: description: Trigger settings for a mental model. example: 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: refresh_after_consolidation: default: false @@ -4176,6 +4204,26 @@ components: \ (real-time mode)" title: Refresh After Consolidation 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 OperationResponse: description: Response model for a single async operation. @@ -4684,6 +4732,26 @@ components: $ref: '#/components/schemas/RecallRequest_tag_groups_inner' nullable: true 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: - query title: ReflectRequest diff --git a/hindsight-clients/go/model_mental_model_trigger.go b/hindsight-clients/go/model_mental_model_trigger.go index ea26fc3b..fe764fb1 100644 --- a/hindsight-clients/go/model_mental_model_trigger.go +++ b/hindsight-clients/go/model_mental_model_trigger.go @@ -21,6 +21,10 @@ var _ MappedNullable = &MentalModelTrigger{} type MentalModelTrigger struct { // If true, refresh this mental model after observations consolidation (real-time mode) 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 @@ -31,6 +35,8 @@ func NewMentalModelTrigger() *MentalModelTrigger { this := MentalModelTrigger{} var refreshAfterConsolidation bool = false this.RefreshAfterConsolidation = &refreshAfterConsolidation + var excludeMentalModels bool = false + this.ExcludeMentalModels = &excludeMentalModels return &this } @@ -41,6 +47,8 @@ func NewMentalModelTriggerWithDefaults() *MentalModelTrigger { this := MentalModelTrigger{} var refreshAfterConsolidation bool = false this.RefreshAfterConsolidation = &refreshAfterConsolidation + var excludeMentalModels bool = false + this.ExcludeMentalModels = &excludeMentalModels return &this } @@ -76,6 +84,104 @@ func (o *MentalModelTrigger) SetRefreshAfterConsolidation(v bool) { 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) { toSerialize,err := o.ToMap() if err != nil { @@ -89,6 +195,15 @@ func (o MentalModelTrigger) ToMap() (map[string]interface{}, error) { if !IsNil(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 } diff --git a/hindsight-clients/go/model_reflect_request.go b/hindsight-clients/go/model_reflect_request.go index fee12935..40a76a2e 100644 --- a/hindsight-clients/go/model_reflect_request.go +++ b/hindsight-clients/go/model_reflect_request.go @@ -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). TagsMatch *string `json:"tags_match,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 @@ -48,6 +52,8 @@ func NewReflectRequest(query string) *ReflectRequest { this.MaxTokens = &maxTokens var tagsMatch string = "any" this.TagsMatch = &tagsMatch + var excludeMentalModels bool = false + this.ExcludeMentalModels = &excludeMentalModels return &this } @@ -60,6 +66,8 @@ func NewReflectRequestWithDefaults() *ReflectRequest { this.MaxTokens = &maxTokens var tagsMatch string = "any" this.TagsMatch = &tagsMatch + var excludeMentalModels bool = false + this.ExcludeMentalModels = &excludeMentalModels return &this } @@ -356,6 +364,104 @@ func (o *ReflectRequest) SetTagGroups(v []RecallRequestTagGroupsInner) { 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) { toSerialize,err := o.ToMap() if err != nil { @@ -391,6 +497,15 @@ func (o ReflectRequest) ToMap() (map[string]interface{}, error) { if o.TagGroups != nil { 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 } diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger.py index 67719376..4899eda2 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger.py @@ -17,7 +17,7 @@ import pprint import re # noqa: F401 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 Optional, Set from typing_extensions import Self @@ -27,7 +27,21 @@ class MentalModelTrigger(BaseModel): Trigger settings for a mental model. """ # noqa: E501 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( populate_by_name=True, @@ -68,6 +82,16 @@ class MentalModelTrigger(BaseModel): exclude=excluded_fields, 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 @classmethod @@ -80,7 +104,10 @@ class MentalModelTrigger(BaseModel): return cls.model_validate(obj) _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 diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py index 4e9ad5c5..9758acb2 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py @@ -17,7 +17,7 @@ import pprint import re # noqa: F401 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 hindsight_client_api.models.budget import Budget 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_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 - __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') 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')") 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( populate_by_name=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: _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 @classmethod @@ -139,7 +163,10 @@ class ReflectRequest(BaseModel): "response_schema": obj.get("response_schema"), "tags": obj.get("tags"), "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 diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 7ecbd4e9..8053da71 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -1331,6 +1331,24 @@ export type MentalModelTrigger = { * If true, refresh this mental model after observations consolidation (real-time mode) */ 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 | null; }; /** @@ -1825,6 +1843,24 @@ export type ReflectRequest = { tag_groups?: Array< TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot > | 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 | null; }; /** diff --git a/hindsight-control-plane/src/app/api/reflect/route.ts b/hindsight-control-plane/src/app/api/reflect/route.ts index 55e0671f..eaf7197b 100644 --- a/hindsight-control-plane/src/app/api/reflect/route.ts +++ b/hindsight-control-plane/src/app/api/reflect/route.ts @@ -14,6 +14,9 @@ export async function POST(request: NextRequest) { tags, tags_match, max_tokens, + fact_types, + exclude_mental_models, + exclude_mental_model_ids, } = body; const requestBody: any = { @@ -22,6 +25,9 @@ export async function POST(request: NextRequest) { tags, tags_match, 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 diff --git a/hindsight-control-plane/src/components/fact-type-filter.tsx b/hindsight-control-plane/src/components/fact-type-filter.tsx new file mode 100644 index 00000000..ee82ebd4 --- /dev/null +++ b/hindsight-control-plane/src/components/fact-type-filter.tsx @@ -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 ( + + ); +} + +/** + * 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 ( +
+ {label && {label}} +
+ {ALL_FACT_TYPES.map((ft) => ( + toggle(ft)} /> + ))} +
+
+ ); +} + +/** + * 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 ( +
+ {ALL_FACT_TYPES.map((ft) => ( + toggle(ft)} /> + ))} +
+ ); +} diff --git a/hindsight-control-plane/src/components/mental-models-view.tsx b/hindsight-control-plane/src/components/mental-models-view.tsx index 06779abe..def248c1 100644 --- a/hindsight-control-plane/src/components/mental-models-view.tsx +++ b/hindsight-control-plane/src/components/mental-models-view.tsx @@ -8,6 +8,8 @@ import { useBank } from "@/lib/bank-context"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; 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 { Card, CardContent } from "@/components/ui/card"; import { @@ -86,6 +88,9 @@ interface MentalModel { max_tokens: number; trigger: { refresh_after_consolidation: boolean; + fact_types?: Array<"world" | "experience" | "observation">; + exclude_mental_models?: boolean; + exclude_mental_model_ids?: string[]; }; last_refreshed_at: string; created_at: string; @@ -593,6 +598,9 @@ function CreateMentalModelDialog({ maxTokens: "2048", tags: "", autoRefresh: false, + factTypes: [] as Array<"world" | "experience" | "observation">, + excludeMentalModels: false, + excludeMentalModelIds: "", }); const handleCreate = async () => { @@ -608,13 +616,23 @@ function CreateMentalModelDialog({ const maxTokens = parseInt(form.maxTokens) || 2048; // 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, { id: form.id.trim() || undefined, name: form.name.trim(), source_query: form.sourceQuery.trim(), tags: tags.length > 0 ? tags : undefined, 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({ @@ -624,6 +642,9 @@ function CreateMentalModelDialog({ maxTokens: "2048", tags: "", autoRefresh: false, + factTypes: [], + excludeMentalModels: false, + excludeMentalModelIds: "", }); onCreated(); } catch (error) { @@ -645,6 +666,9 @@ function CreateMentalModelDialog({ maxTokens: "2048", tags: "", autoRefresh: false, + factTypes: [], + excludeMentalModels: false, + excludeMentalModelIds: "", }); onClose(); } @@ -659,80 +683,111 @@ function CreateMentalModelDialog({ -
-
- - setForm({ ...form, id: e.target.value })} - placeholder="e.g., team-communication" - /> -

- Custom ID for the mental model. If not provided, a UUID will be generated. -

-
-
- - setForm({ ...form, name: e.target.value })} - placeholder="e.g., Team Communication Preferences" - /> -
-
- - setForm({ ...form, sourceQuery: e.target.value })} - placeholder="e.g., How does the team prefer to communicate?" - /> -

- This query will be run to generate the initial content, and re-run when you refresh. -

-
-
- - setForm({ ...form, maxTokens: e.target.value })} - placeholder="2048" - min="256" - max="8192" - /> -

- Maximum tokens for the generated response (256-8192). -

-
-
- - setForm({ ...form, tags: e.target.value })} - placeholder="e.g., project-x, team-alpha (comma-separated)" - /> -
-
- setForm({ ...form, autoRefresh: checked === true })} - /> - -
-

- Automatically refresh this mental model when memories are consolidated. -

-
+ + + + General + + + Options + + + + +
+ + setForm({ ...form, id: e.target.value })} + placeholder="e.g., team-communication" + /> +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g., Team Communication Preferences" + /> +
+
+ + setForm({ ...form, sourceQuery: e.target.value })} + placeholder="e.g., How does the team prefer to communicate?" + /> +
+
+ + setForm({ ...form, maxTokens: e.target.value })} + placeholder="2048" + min="256" + max="8192" + /> +
+
+ + +
+ + setForm({ ...form, tags: e.target.value })} + placeholder="e.g., project-x, team-alpha (comma-separated)" + /> +
+
+ setForm({ ...form, autoRefresh: checked === true })} + /> + +
+
+ + setForm({ ...form, factTypes: v as FactType[] })} + /> +

Leave empty to include all types.

+
+
+ + setForm({ ...form, excludeMentalModels: checked === true }) + } + /> + +
+
+ + setForm({ ...form, excludeMentalModelIds: e.target.value })} + placeholder="e.g., model-a, model-b (comma-separated)" + /> +
+
+