Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 45ed02cd6a fix 2026-01-16 10:49:56 +01:00
Nicolò Boschi 2921fa091b fix 2026-01-16 10:21:05 +01:00
Nicolò Boschi aafa7ae17c fix ci 2026-01-16 07:52:38 +01:00
Nicolò Boschi caeab8ac44 more 2026-01-15 18:34:17 +01:00
Nicolò Boschi 42cc098a7c new style 2026-01-14 18:57:16 +01:00
Nicolò Boschi 2871ddc34c reflect agent 2026-01-14 09:29:36 +01:00
Nicolò Boschi 987b47e6a1 agentic 2026-01-14 09:29:36 +01:00
Nicolò Boschi 25fb4b9273 agentic 2026-01-14 09:29:36 +01:00
Nicolò Boschi f72c9f03fb fix db patch 2026-01-14 09:26:01 +01:00
Nicolò Boschi 517eee4eda DRAFT: refactor entity observations 2026-01-14 09:25:27 +01:00
Nicolò Boschi e19c6b9252 mental models 2026-01-14 09:25:27 +01:00
99 changed files with 17400 additions and 2950 deletions
+39
View File
@@ -0,0 +1,39 @@
[databases]
; Connect to pg0 on port 5433
; The actual pg0 database is called "hindsight"
hindsight = host=127.0.0.1 port=5433 dbname=hindsight user=hindsight password=hindsight
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
; Use md5 authentication (matches pg0's auth)
auth_type = md5
auth_file = /Users/nicoloboschi/dev/memory-poc/.pgbouncer/userlist.txt
; Transaction pooling mode (recommended for hindsight)
pool_mode = transaction
; Reset connection state after each transaction
server_reset_query = DISCARD ALL
; Pool sizing
default_pool_size = 20
max_client_conn = 200
min_pool_size = 5
; Timeouts
server_idle_timeout = 600
server_lifetime = 3600
query_timeout = 120
; Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
; Stats
stats_period = 60
; Admin console
admin_users = admin
+2
View File
@@ -0,0 +1,2 @@
"hindsight" "md5d842ccb6249bcd3c53b2f648378092a6"
"admin" ""
@@ -0,0 +1,112 @@
"""mental_models_v4
Revision ID: h3c4d5e6f7g8
Revises: g2a3b4c5d6e7
Create Date: 2026-01-08 00:00:00.000000
This migration implements the v4 mental models system:
1. Deletes existing observation memory_units (observations now in mental models)
2. Adds mission column to banks (replacing background)
3. Creates mental_models table with final schema
Mental models can reference entities when an entity is "promoted" to a mental model.
Summary content is stored as JSONB observations with per-observation fact attribution.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "h3c4d5e6f7g8"
down_revision: str | Sequence[str] | None = "g2a3b4c5d6e7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Apply mental models v4 changes."""
schema = _get_schema_prefix()
# Step 1: Delete observation memory_units (cascades to unit_entities links)
# Observations are now handled through mental models, not memory_units
op.execute(f"DELETE FROM {schema}memory_units WHERE fact_type = 'observation'")
# Step 2: Drop observation-specific index (if it exists)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_observation_date")
# Step 3: Add mission column to banks (replacing background)
op.execute(f"ALTER TABLE {schema}banks ADD COLUMN IF NOT EXISTS mission TEXT")
# Migrate: copy background to mission if background column exists
# Use DO block to check column existence first (idempotent for re-runs)
schema_name = context.config.get_main_option("target_schema") or "public"
op.execute(f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = '{schema_name}' AND table_name = 'banks' AND column_name = 'background'
) THEN
UPDATE {schema}banks
SET mission = background
WHERE mission IS NULL;
END IF;
END $$;
""")
# Remove background column (replaced by mission)
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS background")
# Step 4: Create mental_models table with final v4 schema (if not exists)
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}mental_models (
id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
subtype VARCHAR(32) NOT NULL,
name VARCHAR(256) NOT NULL,
description TEXT NOT NULL,
entity_id UUID,
observations JSONB DEFAULT '{{"observations": []}}'::jsonb,
links VARCHAR[],
tags VARCHAR[] DEFAULT '{{}}',
last_updated TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
PRIMARY KEY (id, bank_id),
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES {schema}entities(id) ON DELETE SET NULL,
CONSTRAINT ck_mental_models_subtype CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'))
)
""")
# Step 5: Create indexes for efficient queries (if not exist)
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_bank_id ON {schema}mental_models(bank_id)")
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_subtype ON {schema}mental_models(bank_id, subtype)")
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_entity_id ON {schema}mental_models(entity_id)")
# GIN index for efficient tags array filtering
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_tags ON {schema}mental_models USING GIN(tags)")
def downgrade() -> None:
"""Revert mental models v4 changes."""
schema = _get_schema_prefix()
# Drop mental_models table (cascades to indexes)
op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
# Add back background column to banks
op.execute(f"ALTER TABLE {schema}banks ADD COLUMN IF NOT EXISTS background TEXT")
# Migrate mission back to background
op.execute(f"UPDATE {schema}banks SET background = mission WHERE background IS NULL")
# Remove mission column
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission")
# Note: Cannot restore deleted observations - they are lost on downgrade
@@ -0,0 +1,41 @@
"""delete_opinions
Revision ID: i4d5e6f7g8h9
Revises: h3c4d5e6f7g8
Create Date: 2026-01-15 00:00:00.000000
This migration removes opinion facts from memory_units.
Opinions are no longer a separate fact type - they are now represented
through mental model observations with confidence scores.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "i4d5e6f7g8h9"
down_revision: str | Sequence[str] | None = "h3c4d5e6f7g8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Delete opinion memory_units."""
schema = _get_schema_prefix()
# Delete opinion memory_units (cascades to unit_entities links)
# Opinions are now handled through mental model observations
op.execute(f"DELETE FROM {schema}memory_units WHERE fact_type = 'opinion'")
def downgrade() -> None:
"""Cannot restore deleted opinions."""
# Note: Cannot restore deleted opinions - they are lost on downgrade
pass
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -196,7 +196,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
Each bank is an isolated memory store (like a separate "brain").
Returns:
JSON list of banks with their IDs, names, dispositions, and backgrounds.
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=RequestContext())
@@ -206,7 +206,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
return f'{{"error": "{e}", "banks": []}}'
@mcp.tool()
async def create_bank(bank_id: str, name: str | None = None, background: str | None = None) -> str:
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
@@ -216,18 +216,18 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
Args:
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
name: Optional human-friendly name for the bank
background: Optional background context about the bank's owner/purpose
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/background if provided
if name is not None or background is not None:
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
bank_id,
name=name,
background=background,
mission=mission,
request_context=RequestContext(),
)
# Fetch updated profile
+17
View File
@@ -76,6 +76,7 @@ ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# Observation thresholds
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
@@ -106,6 +107,9 @@ ENV_TASK_BACKEND = "HINDSIGHT_API_TASK_BACKEND"
ENV_TASK_BACKEND_MEMORY_BATCH_SIZE = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_SIZE"
ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_INTERVAL"
# Reflect agent settings
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_LLM_PROVIDER = "openai"
@@ -145,6 +149,7 @@ DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traver
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
# Observation thresholds
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
@@ -172,6 +177,9 @@ DEFAULT_TASK_BACKEND = "memory" # Options: "memory", "noop"
DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE = 10
DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL = 1.0 # seconds
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -261,6 +269,7 @@ class HindsightConfig:
mpfp_top_k_neighbors: int
recall_max_concurrent: int
recall_connection_budget: int
mental_model_refresh_concurrency: int
# Observation thresholds
observation_min_facts: int
@@ -291,6 +300,9 @@ class HindsightConfig:
task_backend_memory_batch_size: int
task_backend_memory_batch_interval: float
# Reflect agent settings
reflect_max_iterations: int
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
@@ -341,6 +353,9 @@ class HindsightConfig:
recall_connection_budget=int(
os.getenv(ENV_RECALL_CONNECTION_BUDGET, str(DEFAULT_RECALL_CONNECTION_BUDGET))
),
mental_model_refresh_concurrency=int(
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
@@ -380,6 +395,8 @@ class HindsightConfig:
task_backend_memory_batch_interval=float(
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL))
),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
)
def get_llm_base_url(self) -> str:
+32 -13
View File
@@ -160,14 +160,14 @@ class MemoryEngineInterface(ABC):
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get bank profile including disposition and background.
Get bank profile including disposition and mission.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Bank profile dict.
Bank profile dict with bank_id, name, disposition, and mission.
"""
...
@@ -190,25 +190,44 @@ class MemoryEngineInterface(ABC):
...
@abstractmethod
async def merge_bank_background(
async def merge_bank_mission(
self,
bank_id: str,
new_info: str,
*,
update_disposition: bool = True,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Merge new background information into bank profile.
Merge new mission information into bank profile.
Args:
bank_id: The memory bank ID.
new_info: New background information to merge.
update_disposition: Whether to infer disposition from background.
new_info: New mission information to merge.
request_context: Request context for authentication.
Returns:
Updated background info.
Updated mission info.
"""
...
@abstractmethod
async def set_bank_mission(
self,
bank_id: str,
mission: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Set the bank's mission (replaces existing).
Args:
bank_id: The memory bank ID.
mission: The mission text.
request_context: Request context for authentication.
Returns:
Dict with bank_id and mission.
"""
...
@@ -518,7 +537,7 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
) -> dict[str, Any]:
"""
List async operations for a bank.
@@ -527,7 +546,7 @@ class MemoryEngineInterface(ABC):
request_context: Request context for authentication.
Returns:
List of operation dicts with id, task_type, status, etc.
Dict with 'total' (int) and 'operations' (list of operation dicts).
"""
...
@@ -561,16 +580,16 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
name: str | None = None,
background: str | None = None,
mission: str | None = None,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Update bank name and/or background.
Update bank name and/or mission.
Args:
bank_id: The memory bank ID.
name: New bank name (optional).
background: New background text (optional, replaces existing).
mission: New mission text (optional, replaces existing).
request_context: Request context for authentication.
Returns:
+483 -43
View File
@@ -209,10 +209,10 @@ class LLMProvider:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
queue_start_time = time.time()
semaphore_start = time.time()
async with _global_llm_semaphore:
semaphore_wait_time = time.time() - semaphore_start
start_time = time.time()
semaphore_wait_time = start_time - queue_start_time
# Handle Mock provider (for testing)
if self.provider == "mock":
@@ -318,43 +318,44 @@ class LLMProvider:
last_exception = None
# Prepare response format ONCE before the retry loop
# (to avoid appending schema to messages on every retry)
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
# This guarantees all required fields are returned
call_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": True,
"schema": schema,
},
}
else:
# Soft enforcement: add schema to prompt and use json_object mode
if schema is not None:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# We rely on the schema in the system message instead
call_params["response_format"] = {"type": "json_object"}
for attempt in range(max_retries + 1):
try:
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
# This guarantees all required fields are returned
call_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": True,
"schema": schema,
},
}
else:
# Soft enforcement: add schema to prompt and use json_object mode
if schema is not None:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# We rely on the schema in the system message instead
call_params["response_format"] = {"type": "json_object"}
logger.debug(f"Sending request to {self.provider}/{self.model} (timeout={self.timeout})")
response = await self._client.chat.completions.create(**call_params)
logger.debug(f"Received response from {self.provider}/{self.model}")
content = response.choices[0].message.content
@@ -467,13 +468,11 @@ class LLMProvider:
except APIConnectionError as e:
last_exception = e
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(f"APIConnectionError (HTTP {status_code}), attempt {attempt + 1}: {str(e)[:200]}")
if attempt < max_retries:
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(
f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
@@ -487,6 +486,45 @@ class LLMProvider:
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
# Handle tool_use_failed error - model outputted in tool call format
# Convert to expected JSON format and continue
if e.status_code == 400 and response_format is not None:
try:
error_body = e.body if hasattr(e, "body") else {}
if isinstance(error_body, dict):
error_info: dict[str, Any] = error_body.get("error") or {}
if error_info.get("code") == "tool_use_failed":
failed_gen = error_info.get("failed_generation", "")
if failed_gen:
# Parse the tool call format and convert to actions format
tool_call = json.loads(failed_gen)
tool_name = tool_call.get("name", "")
tool_args = tool_call.get("arguments", {})
# Convert to actions format: {"actions": [{"tool": "name", ...args}]}
converted = {"actions": [{"tool": tool_name, **tool_args}]}
if skip_validation:
result = converted
else:
result = response_format.model_validate(converted)
# Record metrics for this successful recovery
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0,
output_tokens=0,
success=True,
)
if return_usage:
return result, TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
return result
except (json.JSONDecodeError, KeyError, TypeError):
pass # Failed to parse tool_use_failed, continue with normal retry
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
@@ -497,14 +535,416 @@ class LLMProvider:
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}")
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("LLM call failed after all retries with no exception captured")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> "LLMToolCallResult":
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from .response_models import LLMToolCall, LLMToolCallResult
async with _global_llm_semaphore:
start_time = time.time()
# Handle Mock provider
if self.provider == "mock":
return await self._call_with_tools_mock(messages, tools, scope)
# Handle Anthropic separately (uses different tool format)
if self.provider == "anthropic":
return await self._call_with_tools_anthropic(
messages, tools, max_completion_tokens, max_retries, initial_backoff, max_backoff, start_time, scope
)
# Handle Gemini (convert to Gemini tool format)
if self.provider == "gemini":
return await self._call_with_tools_gemini(
messages, tools, max_retries, initial_backoff, max_backoff, start_time, scope
)
# OpenAI-compatible providers (OpenAI, Groq, Ollama, LMStudio)
call_params: dict[str, Any] = {
"model": self.model,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
call_params["temperature"] = temperature
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.chat.completions.create(**call_params)
message = response.choices[0].message
finish_reason = response.choices[0].finish_reason
# Extract tool calls if present
tool_calls: list[LLMToolCall] = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments) if tc.function.arguments else {}
except json.JSONDecodeError:
args = {"_raw": tc.function.arguments}
tool_calls.append(LLMToolCall(id=tc.id, name=tc.function.name, arguments=args))
content = message.content
# Record metrics
duration = time.time() - start_time
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
except APIStatusError as e:
if e.status_code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("Tool call failed after all retries")
async def _call_with_tools_mock(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
scope: str,
) -> "LLMToolCallResult":
"""Handle mock tool calls for testing."""
from .response_models import LLMToolCallResult
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
}
self._mock_calls.append(call_record)
if self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
return self._mock_response
# Allow setting just tool calls as a list
if isinstance(self._mock_response, list):
from .response_models import LLMToolCall
return LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(self._mock_response)
],
finish_reason="tool_calls",
)
return LLMToolCallResult(content="mock response", finish_reason="stop")
async def _call_with_tools_anthropic(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
start_time: float,
scope: str,
) -> "LLMToolCallResult":
"""Handle Anthropic tool calling."""
from anthropic import APIConnectionError, APIStatusError
from .response_models import LLMToolCall, LLMToolCallResult
# Convert OpenAI tool format to Anthropic format
anthropic_tools = []
for tool in tools:
func = tool.get("function", {})
anthropic_tools.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {"type": "object", "properties": {}}),
}
)
# Convert messages - handle tool results
system_prompt = None
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt = (system_prompt + "\n\n" + content) if system_prompt else content
elif role == "tool":
# Anthropic uses tool_result blocks
anthropic_messages.append(
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": msg.get("tool_call_id", ""), "content": content}
],
}
)
elif role == "assistant" and msg.get("tool_calls"):
# Convert assistant tool calls
tool_use_blocks = []
for tc in msg["tool_calls"]:
tool_use_blocks.append(
{
"type": "tool_use",
"id": tc.get("id", ""),
"name": tc.get("function", {}).get("name", ""),
"input": json.loads(tc.get("function", {}).get("arguments", "{}")),
}
)
anthropic_messages.append({"role": "assistant", "content": tool_use_blocks})
else:
anthropic_messages.append({"role": role, "content": content})
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
"tools": anthropic_tools,
"max_tokens": max_completion_tokens or 4096,
}
if system_prompt:
call_params["system"] = system_prompt
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._anthropic_client.messages.create(**call_params)
# Extract content and tool calls
content_parts = []
tool_calls: list[LLMToolCall] = []
for block in response.content:
if block.type == "text":
content_parts.append(block.text)
elif block.type == "tool_use":
tool_calls.append(LLMToolCall(id=block.id, name=block.name, arguments=block.input or {}))
content = "".join(content_parts) if content_parts else None
finish_reason = "tool_calls" if tool_calls else "stop"
# Record metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
input_tokens=response.usage.input_tokens or 0,
output_tokens=response.usage.output_tokens or 0,
success=True,
)
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except (APIConnectionError, APIStatusError) as e:
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
if last_exception:
raise last_exception
raise RuntimeError("Anthropic tool call failed")
async def _call_with_tools_gemini(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_retries: int,
initial_backoff: float,
max_backoff: float,
start_time: float,
scope: str,
) -> "LLMToolCallResult":
"""Handle Gemini tool calling."""
from .response_models import LLMToolCall, LLMToolCallResult
# Convert tools to Gemini format
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
# Convert messages
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
elif role == "tool":
# Gemini uses function_response
gemini_contents.append(
genai_types.Content(
role="user",
parts=[
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=msg.get("name", ""),
response={"result": content},
)
)
],
)
)
elif role == "assistant":
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
config = genai_types.GenerateContentConfig(
system_instruction=system_instruction,
tools=gemini_tools,
)
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._gemini_client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=config,
)
# Extract content and tool calls
content = None
tool_calls: list[LLMToolCall] = []
if response.candidates and response.candidates[0].content:
for part in response.candidates[0].content.parts:
if hasattr(part, "text") and part.text:
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
)
)
finish_reason = "tool_calls" if tool_calls else "stop"
# Record metrics
metrics = get_metrics_collector()
input_tokens = response.usage_metadata.prompt_token_count if response.usage_metadata else 0
output_tokens = response.usage_metadata.candidates_token_count if response.usage_metadata else 0
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except genai_errors.APIError as e:
if e.code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
if last_exception:
raise last_exception
raise RuntimeError("Gemini tool call failed")
async def _call_anthropic(
self,
messages: list[dict[str, str]],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
"""
Mental models module for Hindsight.
Mental models are synthesized summaries that represent understanding. They come
in different subtypes based on how they were created:
- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team")
These are created upfront based on what any agent with this role would need.
- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.)
These surface organically as facts are retained.
- Pinned: User-defined models that persist across refreshes.
"""
from .models import MentalModel, MentalModelSubtype
__all__ = ["MentalModel", "MentalModelSubtype"]
@@ -0,0 +1,311 @@
"""
Emergent mental model detection and promotion.
Emergent models are discovered from data patterns:
- Named entity extraction (people, projects, systems)
- Temporal clustering (events with multiple references)
- Causal patterns ("Because X, we do Y")
- Behavioral anchors ("After X, we started Y")
- Reference frequency (anything mentioned repeatedly)
When a pattern is detected, it goes through a mission filter to check relevance,
and if relevant, is promoted to a mental model.
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import EmergentCandidate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class MissionFilterCandidate(BaseModel):
"""Result of mission filtering for a single candidate."""
name: str
promote: bool = Field(description="True if this is a specific named entity worth tracking")
reason: str = Field(description="Brief explanation for the decision")
class MissionFilterResponse(BaseModel):
"""Response from LLM for mission filtering."""
candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
"""Build the prompt for filtering candidates by mission relevance."""
candidate_list = "\n".join(
[f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
)
return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
MISSION: {mission}
DETECTED ENTITIES:
{candidate_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
- "Maria" → YES (promote=true)
- "park" → NO (promote=false)
When in doubt, set promote=false."""
def get_mission_filter_system_message() -> str:
"""System message for mission filtering."""
return """You filter entities for promotion. Output JSON with 'candidates' array.
Rules:
- promote=true ONLY for specific names (people, organizations, named places/projects)
- promote=false for common words, generic categories, abstract concepts
Examples:
- "John" → promote=true (person name)
- "kids" → promote=false (generic category)
- "community" → promote=false (abstract concept)
- "Google" → promote=true (organization name)
- "motivation" → promote=false (abstract concept)
When in doubt, promote=false. Most entities should be rejected."""
async def filter_candidates_by_mission(
llm_config: "LLMConfig",
mission: str,
candidates: list[EmergentCandidate],
) -> list[EmergentCandidate]:
"""
Filter emergent candidates to keep only specific, named entities.
Args:
llm_config: LLM configuration
mission: The bank's mission (used for context)
candidates: List of detected candidates
Returns:
Filtered list of candidates that are specific named entities
"""
if not candidates:
return []
if not mission:
# No mission = no filtering, keep all candidates
logger.debug("[EMERGENT] No mission set, skipping filter")
return candidates
prompt = build_mission_filter_prompt(mission, candidates)
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_mission_filter",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Filter candidates
filtered = []
for candidate in candidates:
if candidate.name in promote_map:
if promote_map[candidate.name]:
filtered.append(candidate)
logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
else:
logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
else:
# Candidate not in response - reject by default
logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
return filtered
except Exception as e:
logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
return []
async def evaluate_emergent_models(
llm_config: "LLMConfig",
models: list[dict],
) -> list[str]:
"""
Evaluate existing emergent models to check if they should be kept.
This re-evaluates emergent models using the same filtering criteria
as new candidates. Models that are generic/abstract will be removed.
Args:
llm_config: LLM configuration
models: List of existing emergent model dicts with 'name', 'id'
Returns:
List of model IDs that should be REMOVED (no longer valid)
"""
if not models:
return []
# Convert existing models to candidates for evaluation
candidates = [
EmergentCandidate(
name=m["name"],
detection_method="existing_emergent_model",
mention_count=0,
)
for m in models
]
# Build a simple prompt for re-evaluation
names_list = "\n".join([f"- {m['name']}" for m in models])
prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
EXISTING MODELS:
{names_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
When in doubt, set promote=false."""
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_emergent_evaluation",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Find models to remove
models_to_remove = []
for model in models:
name = model["name"]
if name in promote_map:
if not promote_map[name]:
models_to_remove.append(model["id"])
else:
logger.debug(f"[EMERGENT] Keeping '{name}'")
else:
# Model not in response - remove to be safe
logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
models_to_remove.append(model["id"])
logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
return models_to_remove
except Exception as e:
logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
return []
async def detect_entity_candidates(
pool,
bank_id: str,
min_mentions: int = 5,
top_percent: int = 20,
) -> list[EmergentCandidate]:
"""
Detect entities that are candidates for promotion to mental models.
Args:
pool: Database connection pool
bank_id: Bank identifier
min_mentions: Minimum mention count to consider
top_percent: Only consider top X% by mention count
Returns:
List of entity candidates
"""
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
candidates = []
async with acquire_with_retry(pool) as conn:
# Get entities that meet criteria and don't already have mental models
rows = await conn.fetch(
f"""
WITH ranked AS (
SELECT
e.id,
e.canonical_name,
e.mention_count,
PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
FROM {fq_table("entities")} e
LEFT JOIN {fq_table("mental_models")} mm
ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
WHERE e.bank_id = $1
AND e.mention_count >= $2
AND mm.id IS NULL -- Not already a mental model
)
SELECT id, canonical_name, mention_count
FROM ranked
WHERE rank_pct <= $3
ORDER BY mention_count DESC
LIMIT 50
""",
bank_id,
min_mentions,
top_percent / 100.0,
)
for row in rows:
candidates.append(
EmergentCandidate(
name=row["canonical_name"],
detection_method="named_entity_extraction",
mention_count=row["mention_count"],
entity_id=str(row["id"]),
relevance_score=0.0,
)
)
logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
return candidates
@@ -0,0 +1,97 @@
"""
Pydantic models for mental models.
"""
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model - how it was created."""
STRUCTURAL = "structural" # Derived from mission, created upfront
EMERGENT = "emergent" # Discovered from data patterns
LEARNED = "learned" # Formed through reflection
PINNED = "pinned" # User-defined, persists across refreshes
class MentalModel(BaseModel):
"""
A mental model representing synthesized understanding.
Mental models are the agent's consolidated knowledge. Unlike raw facts,
mental models provide:
- A one-liner description for quick scanning/retrieval
- A full summary for deep understanding
- Links to related mental models
"""
id: str = Field(description="Unique identifier within the bank")
bank_id: str = Field(description="Bank this mental model belongs to")
subtype: MentalModelSubtype = Field(description="How this model was created")
name: str = Field(description="Human-readable name")
description: str = Field(description="One-liner for quick scanning and retrieval matching")
summary: str | None = Field(default=None, description="Full synthesized understanding")
# References
entity_id: str | None = Field(default=None, description="Reference to entities table when type=entity")
source_facts: list[str] = Field(default_factory=list, description="Fact IDs used to generate summary")
links: list[str] = Field(default_factory=list, description="Related mental model IDs")
# Tags for scoped visibility (similar to document tags)
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility filtering")
# Timestamps
last_updated: datetime | None = Field(default=None, description="When summary was last regenerated")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
class StructuralModelTemplate(BaseModel):
"""
A template for a structural mental model.
Generated by LLM based on the bank's mission. Represents what any agent
with this role would need to track.
"""
id: str = Field(default="", description="Existing model ID to keep, or empty for new models")
name: str = Field(description="Human-readable name")
description: str = Field(description="What this model should track")
initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model")
class StructuralModelDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class EmergentCandidate(BaseModel):
"""
A candidate for promotion to emergent mental model.
Detected through pattern analysis of facts.
"""
name: str = Field(description="Name of the detected pattern/entity")
detection_method: str = Field(description="How this candidate was detected")
mention_count: int = Field(default=0, description="How many times referenced")
entity_id: str | None = Field(default=None, description="Entity ID if detected as entity")
relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)")
class ResearchResult(BaseModel):
"""
Result from the research endpoint.
Contains the answer along with the mental models and facts used.
"""
answer: str = Field(description="The synthesized answer")
mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed")
facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed")
question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)")
@@ -0,0 +1,228 @@
"""
Structural mental model derivation from bank mission.
Structural models are derived from the bank's mission - they represent what
any agent with this role would need to track. For example:
Mission: "Be a PM for engineering team"
Structural models:
- Team Structure (who's on the team, roles)
- Project Overview (current projects, status)
- Processes (how releases work, how decisions are made)
- Key Systems (what we own, dependencies)
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import StructuralModelTemplate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class StructuralDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class StructuralRelevanceResult(BaseModel):
"""Result of evaluating a structural model's relevance to the mission."""
name: str
relevant: bool
reason: str
class StructuralRelevanceResponse(BaseModel):
"""Response from LLM for structural model relevance evaluation."""
models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model")
def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str:
"""Build the prompt for deriving structural models from a mission."""
existing_section = ""
if existing_models:
model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models])
existing_section = f"""
EXISTING STRUCTURAL MODELS:
{model_list}
IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value.
Models not included in your output will be REMOVED.
"""
return f"""Given this agent mission, identify the KEY THINGS to track to achieve it.
MISSION: {mission}
{existing_section}
IMPORTANT CONSTRAINTS:
- Return 0-3 structural models MAXIMUM (less is better!)
- Only include models for SPECIFIC, CONCRETE things the agent needs to track
- Each model must be DIRECTLY tied to achieving the mission
- If the mission is simple, return 0 models (empty array is fine)
- If existing models are provided and you want to keep one, use its EXACT id
- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists)
GOOD examples (specific, actionable):
- Mission: "Be a PM for engineering team""Team Members" (track who's on the team)
- Mission: "Track customer feedback""Customer Issues" (track specific complaints/requests)
- Mission: "Manage project X""Project X Milestones" (track progress)
BAD examples (too generic, don't create these):
- "Processes", "Workflows", "Key Systems", "Important Events"
- "Communication", "Collaboration", "Progress", "Status"
- Generic role-based models not tied to the specific mission
For each model:
1. id: Use EXACT existing id if keeping a model, or leave empty for new models
2. name: Short, specific name (e.g., "Team Members", "Sprint Goals")
3. description: One line describing what to track
4. initial_probes: 2-3 search queries to find relevant information
Return ONLY the models that should exist. Existing models not in your output will be deleted."""
def get_structural_derivation_system_message() -> str:
"""System message for structural model derivation."""
return """You identify the key things to track for a mission. Be VERY selective.
Rules:
- Maximum 3 models (prefer fewer)
- Only SPECIFIC, CONCRETE things - not generic categories
- Each must DIRECTLY help achieve the mission
- Empty array is valid if no models are truly needed
- If existing models are shown and you want to keep one, return its EXACT id
- Never create duplicates - if a similar model exists, keep the existing one
Output JSON with 'templates' array (can be empty)."""
def _normalize_id(text: str) -> str:
"""Normalize a string to a canonical form for comparison.
Removes common suffixes, pluralization, and normalizes separators.
"""
# Lowercase and normalize separators
normalized = text.lower().replace(" ", "-").replace("_", "-")
# Remove common suffixes that indicate the same concept
suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"]
for suffix in suffixes_to_remove:
if normalized.endswith(suffix) and len(normalized) > len(suffix):
normalized = normalized[: -len(suffix)]
return normalized
def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None:
"""Find an existing model ID that is similar to the new ID.
Returns the existing ID if a similar one is found, None otherwise.
"""
if not existing_models:
return None
new_normalized = _normalize_id(new_id)
for model in existing_models:
existing_id = model.get("id", "")
existing_normalized = _normalize_id(existing_id)
# Check if one is a prefix of the other (normalized)
if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized):
return existing_id
# Check if they're the same when normalized
if new_normalized == existing_normalized:
return existing_id
return None
async def derive_structural_models(
llm_config: "LLMConfig",
mission: str,
existing_models: list[dict] | None = None,
) -> tuple[list[StructuralModelTemplate], list[str]]:
"""
Derive structural model templates from a bank's mission.
This combines derivation and evaluation in one call. The LLM sees existing
models and decides which to keep. Any existing model not in the output
will be marked for removal.
Args:
llm_config: LLM configuration for calling the model
mission: The bank's mission (e.g., "Be a PM for engineering team")
existing_models: Optional list of existing model dicts with 'name', 'description', 'id'
Returns:
Tuple of (templates to create/keep, IDs of existing models to remove)
Raises:
Exception: If LLM call fails
"""
prompt = build_structural_derivation_prompt(mission, existing_models)
result = await llm_config.call(
messages=[
{"role": "system", "content": get_structural_derivation_system_message()},
{"role": "user", "content": prompt},
],
response_format=StructuralDerivationResponse,
scope="mental_model_structural_derivation",
)
templates = result.templates
logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models")
# Build set of existing IDs for quick lookup
existing_ids = {m["id"] for m in existing_models} if existing_models else set()
# Process templates: validate IDs, deduplicate, assign stable IDs
processed_templates: list[StructuralModelTemplate] = []
kept_existing_ids: set[str] = set()
for template in templates:
# If LLM returned an ID, check if it's a valid existing ID
if template.id and template.id in existing_ids:
# LLM is keeping an existing model
kept_existing_ids.add(template.id)
processed_templates.append(template)
logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}")
else:
# New model or LLM didn't return a valid ID
# Generate ID from name
generated_id = template.name.lower().replace(" ", "-").replace("_", "-")
# Check for similar existing models to prevent near-duplicates
similar_id = _find_similar_existing_id(generated_id, existing_models)
if similar_id and similar_id not in kept_existing_ids:
# Use the existing similar model instead of creating a new one
logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'")
template.id = similar_id
kept_existing_ids.add(similar_id)
else:
template.id = generated_id
processed_templates.append(template)
# Find existing models to remove (not kept in LLM output)
models_to_remove = []
if existing_models:
for model in existing_models:
if model["id"] not in kept_existing_ids:
logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal")
models_to_remove.append(model["id"])
if models_to_remove:
logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed")
return processed_templates, models_to_remove
@@ -0,0 +1,20 @@
"""
Reflect agent module for agentic reflection with tools.
The reflect agent uses an iterative loop with tools to:
1. Lookup mental models (existing knowledge)
2. Recall facts (semantic + temporal search)
3. Learn new insights (create/update mental models)
4. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .models import MentalModelInput, ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectAction",
"ReflectActionBatch",
"MentalModelInput",
]
@@ -0,0 +1,731 @@
"""
Reflect agent - agentic loop for reflection with native tool calling.
"""
import asyncio
import json
import logging
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal
from .models import LLMCall, MentalModelInput, Observation, ReflectAgentResult, ToolCall
from .prompts import FINAL_SYSTEM_PROMPT, build_final_prompt, build_system_prompt_for_tools
from .tools_schema import get_reflect_tools
if TYPE_CHECKING:
from ..llm_wrapper import LLMProvider
from ..response_models import LLMToolCall
logger = logging.getLogger(__name__)
DEFAULT_MAX_ITERATIONS = 10
async def _generate_structured_output(
answer: str,
response_schema: dict,
llm_config: "LLMProvider",
reflect_id: str,
) -> dict[str, Any] | None:
"""Generate structured output from an answer using the provided JSON schema.
Args:
answer: The text answer to extract structured data from
response_schema: JSON Schema for the expected output structure
llm_config: LLM provider for making the extraction call
reflect_id: Reflect ID for logging
Returns:
Structured output dict if successful, None otherwise
"""
try:
from typing import Any as TypingAny
from pydantic import create_model
def _json_schema_type_to_python(field_schema: dict) -> type:
"""Map JSON schema type to Python type for better LLM guidance."""
json_type = field_schema.get("type", "string")
if json_type == "array":
return list
elif json_type == "object":
return dict
elif json_type == "integer":
return int
elif json_type == "number":
return float
elif json_type == "boolean":
return bool
else:
return str
# Build fields from JSON schema properties
schema_props = response_schema.get("properties", {})
required_fields = set(response_schema.get("required", []))
fields: dict[str, TypingAny] = {}
for field_name, field_schema in schema_props.items():
field_type = _json_schema_type_to_python(field_schema)
default = ... if field_name in required_fields else None
fields[field_name] = (field_type, default)
if not fields:
return None
DynamicModel = create_model("StructuredResponse", **fields)
# Include the full schema in the prompt for better LLM guidance
schema_str = json.dumps(response_schema, indent=2)
# Call LLM with the answer to extract structured data
structured_prompt = f"""Based on this answer, extract the information into the requested structured format.
Answer: {answer}
JSON Schema to follow:
```json
{schema_str}
```
Return ONLY a valid JSON object that matches this exact schema. Pay special attention to field types:
- "type": "array" means the value must be a JSON array/list, NOT a string
- "type": "string" means the value must be a string
- "type": "object" means the value must be a JSON object
Do not include any explanation, only the JSON object."""
structured_result = await llm_config.call(
messages=[
{
"role": "system",
"content": "Extract structured data from the given answer. Return only valid JSON matching the provided schema exactly.",
},
{"role": "user", "content": structured_prompt},
],
response_format=DynamicModel,
scope="reflect_structured",
skip_validation=True, # We'll handle the dict ourselves
)
# Convert to dict
if hasattr(structured_result, "model_dump"):
structured_output = structured_result.model_dump()
elif isinstance(structured_result, dict):
structured_output = structured_result
else:
# Try to parse as JSON
structured_output = json.loads(str(structured_result))
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
return structured_output
except Exception as e:
logger.warning(f"[REFLECT {reflect_id}] Failed to generate structured output: {e}")
return None
async def run_reflect_agent(
llm_config: "LLMProvider",
bank_id: str,
query: str,
bank_profile: dict[str, Any],
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
context: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
max_tokens: int | None = None,
response_schema: dict | None = None,
output_mode: Literal["answer", "observations"] = "answer",
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
The agent iteratively calls tools to gather information and learn,
then provides a final answer via the done() tool.
Args:
llm_config: LLM provider for agent calls
bank_id: Bank identifier
query: Question to answer
bank_profile: Bank profile with name and mission
lookup_fn: Tool callback for lookup (model_id) -> result
recall_fn: Tool callback for recall (query, max_tokens) -> result
expand_fn: Tool callback for expand (memory_id, depth) -> result
learn_fn: Optional tool callback for learn (MentalModelInput) -> result.
If None, learn tool is disabled.
context: Optional additional context
max_iterations: Maximum number of iterations before forcing response
max_tokens: Maximum tokens for the final response
response_schema: Optional JSON Schema for structured output in final response
output_mode: "answer" returns final text, "observations" returns structured observations
Returns:
ReflectAgentResult with final answer and metadata
"""
enable_learn = learn_fn is not None
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
start_time = time.time()
# Get tools for this agent
tools = get_reflect_tools(enable_learn=enable_learn, output_mode=output_mode)
# Build initial messages
system_prompt = build_system_prompt_for_tools(bank_profile, context, output_mode=output_mode)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
# Tracking
mental_models_created: list[str] = []
total_tools_called = 0
tool_trace: list[ToolCall] = []
tool_trace_summary: list[dict[str, Any]] = []
llm_trace: list[dict[str, Any]] = []
context_history: list[dict[str, Any]] = [] # For final prompt fallback
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
available_model_ids: set[str] = set()
# In answer mode, pre-fetch mental models so the agent always starts with this knowledge
if output_mode == "answer":
prefetch_start = time.time()
models_result = await lookup_fn(None) # List all mental models
prefetch_duration = int((time.time() - prefetch_start) * 1000)
# Track available model IDs
if isinstance(models_result, dict) and "models" in models_result:
for model in models_result["models"]:
if "id" in model:
available_model_ids.add(model["id"])
# Add to context history for the agent
context_history.append({"tool": "list_mental_models", "output": models_result})
# Add to tool trace
tool_trace.append(
ToolCall(
tool="list_mental_models",
input={"tool": "list_mental_models"},
output=models_result,
duration_ms=prefetch_duration,
iteration=0,
)
)
tool_trace_summary.append(
{
"tool": "list_mental_models",
"input_summary": "(prefetch)",
"duration_ms": prefetch_duration,
"output_chars": len(json.dumps(models_result, default=str)),
}
)
total_tools_called += 1
# Include in the user message so the agent sees it
models_info = json.dumps(models_result, indent=2, default=str)
messages[1]["content"] = f"{query}\n\n## Available Mental Models (pre-fetched)\n```json\n{models_info}\n```"
def _get_llm_trace() -> list[LLMCall]:
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
def _log_completion(answer: str, iterations: int, forced: bool = False):
elapsed_ms = int((time.time() - start_time) * 1000)
tools_summary = (
", ".join(
f"{t['tool']}({t['input_summary']})={t['duration_ms']}ms/{t.get('output_chars', 0)}c"
for t in tool_trace_summary
)
or "none"
)
llm_summary = ", ".join(f"{c['scope']}={c['duration_ms']}ms" for c in llm_trace) or "none"
total_llm_ms = sum(c["duration_ms"] for c in llm_trace)
total_tools_ms = sum(t["duration_ms"] for t in tool_trace_summary)
answer_preview = answer[:100] + "..." if len(answer) > 100 else answer
mode = "forced" if forced else "done"
logger.info(
f"[REFLECT {reflect_id}] {mode} | "
f"query='{query[:50]}...' | "
f"iterations={iterations} | "
f"llm=[{llm_summary}] ({total_llm_ms}ms) | "
f"tools=[{tools_summary}] ({total_tools_ms}ms) | "
f"answer='{answer_preview}' | "
f"total={elapsed_ms}ms"
)
for iteration in range(max_iterations):
is_last = iteration == max_iterations - 1
if is_last:
# Force text response on last iteration - no tools
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
)
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
)
# Call LLM with tools
llm_start = time.time()
try:
result = await llm_config.call_with_tools(
messages=messages,
tools=tools,
scope="reflect_agent",
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
llm_trace.append({"scope": f"agent_{iteration + 1}", "duration_ms": llm_duration})
except Exception:
llm_trace.append(
{"scope": f"agent_{iteration + 1}_err", "duration_ms": int((time.time() - llm_start) * 1000)}
)
# Guardrail: If no evidence gathered yet, retry
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
if not has_gathered_evidence and iteration < max_iterations - 1:
continue
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
)
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
)
# No tool calls - LLM wants to respond with text
if not result.tool_calls:
if result.content:
answer = result.content.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
_log_completion(answer, iteration + 1)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
)
# Empty response, force final
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
)
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
)
# Check for done tool call
done_call = next((tc for tc in result.tool_calls if tc.name == "done"), None)
if done_call:
# Guardrail: Require evidence before done
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
if not has_gathered_evidence and iteration < max_iterations - 1:
# Add assistant message and fake tool result asking for evidence
messages.append(
{
"role": "assistant",
"tool_calls": [_tool_call_to_dict(done_call)],
}
)
messages.append(
{
"role": "tool",
"tool_call_id": done_call.id,
"content": json.dumps(
{
"error": "You must call recall() or list_mental_models() to gather evidence before providing your final answer."
}
),
}
)
continue
# Process done tool
return await _process_done_tool(
done_call,
output_mode,
available_memory_ids,
available_model_ids,
iteration + 1,
total_tools_called,
mental_models_created,
tool_trace,
_get_llm_trace(),
_log_completion,
reflect_id,
llm_config=llm_config,
response_schema=response_schema,
)
# Execute other tools in parallel
other_tools = [tc for tc in result.tool_calls if tc.name != "done"]
if other_tools:
# Add assistant message with tool calls
messages.append(
{
"role": "assistant",
"tool_calls": [_tool_call_to_dict(tc) for tc in other_tools],
}
)
# Execute tools in parallel
tool_tasks = [
_execute_tool_with_timing(tc, lookup_fn, recall_fn, expand_fn, learn_fn) for tc in other_tools
]
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
total_tools_called += len(other_tools)
# Process results and add to messages
for tc, result_data in zip(other_tools, tool_results):
if isinstance(result_data, Exception):
# Tool execution failed - log and raise to fail the request
logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
raise RuntimeError(f"Reflect tool '{tc.name}' failed: {result_data}")
output, duration_ms = result_data
# Check if tool returned an error response
if isinstance(output, dict) and "error" in output:
logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} returned error: {output['error']}")
raise RuntimeError(f"Reflect tool '{tc.name}' error: {output['error']}")
# Track created mental models
if tc.name == "learn" and isinstance(output, dict) and "model_id" in output:
mental_models_created.append(output["model_id"])
# Track available memory IDs from recall
if tc.name == "recall" and isinstance(output, dict) and "memories" in output:
for memory in output["memories"]:
if "id" in memory:
available_memory_ids.add(memory["id"])
# Track available model IDs
if tc.name in ("list_mental_models", "get_mental_model") and isinstance(output, dict):
if output.get("found") and "model" in output:
model_id = output["model"].get("id")
if model_id:
available_model_ids.add(model_id)
elif "models" in output:
for model in output["models"]:
if "id" in model:
available_model_ids.add(model["id"])
# Add tool result message
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(output, default=str),
}
)
# Track for logging and context history
input_dict = {"tool": tc.name, **tc.arguments}
input_summary = _summarize_input(tc.name, tc.arguments)
tool_trace.append(
ToolCall(
tool=tc.name, input=input_dict, output=output, duration_ms=duration_ms, iteration=iteration + 1
)
)
try:
output_chars = len(json.dumps(output))
except (TypeError, ValueError):
output_chars = len(str(output))
tool_trace_summary.append(
{
"tool": tc.name,
"input_summary": input_summary,
"duration_ms": duration_ms,
"output_chars": output_chars,
}
)
# Keep context history for fallback final prompt
context_history.append({"tool": tc.name, "input": input_dict, "output": output})
# Should not reach here
answer = "I was unable to formulate a complete answer within the iteration limit."
_log_completion(answer, max_iterations, forced=True)
return ReflectAgentResult(
text=answer,
iterations=max_iterations,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
)
def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
"""Convert LLMToolCall to OpenAI message format."""
return {
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments),
},
}
async def _process_done_tool(
done_call: "LLMToolCall",
output_mode: str,
available_memory_ids: set[str],
available_model_ids: set[str],
iterations: int,
total_tools_called: int,
mental_models_created: list[str],
tool_trace: list[ToolCall],
llm_trace: list[LLMCall],
log_completion: Callable,
reflect_id: str,
llm_config: "LLMProvider | None" = None,
response_schema: dict | None = None,
) -> ReflectAgentResult:
"""Process the done tool call and return the result."""
args = done_call.arguments
if output_mode == "observations" and "observations" in args:
# Process observations - handle both list and nested {"observations": [...]} format
observations: list[Observation] = []
used_memory_ids: list[str] = []
obs_list = args["observations"]
# Handle nested format where LLM outputs {"observations": [...]} instead of just [...]
if isinstance(obs_list, dict) and "observations" in obs_list:
obs_list = obs_list["observations"]
for obs_data in obs_list:
validated_mids = []
for mid in obs_data.get("memory_ids", []):
if mid in available_memory_ids:
validated_mids.append(mid)
if mid not in used_memory_ids:
used_memory_ids.append(mid)
observations.append(
Observation(
title=obs_data.get("title", ""),
text=obs_data.get("text", ""),
memory_ids=validated_mids,
)
)
# Build text from observations
text_parts = []
for obs in observations:
if obs.title:
text_parts.append(f"## {obs.title}\n{obs.text}")
else:
text_parts.append(obs.text)
answer = "\n\n".join(text_parts)
log_completion(answer, iterations)
return ReflectAgentResult(
text=answer,
observations=observations,
iterations=iterations,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=llm_trace,
used_memory_ids=used_memory_ids,
)
# Default: answer mode
answer = args.get("answer", "").strip()
if not answer:
answer = "No answer provided."
# Validate IDs
used_memory_ids = [mid for mid in args.get("memory_ids", []) if mid in available_memory_ids]
used_model_ids = [mid for mid in args.get("model_ids", []) if mid in available_model_ids]
# Generate structured output if schema provided
structured_output = None
if response_schema and llm_config and answer:
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
log_completion(answer, iterations)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iterations,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=llm_trace,
used_memory_ids=used_memory_ids,
used_model_ids=used_model_ids,
)
async def _execute_tool_with_timing(
tc: "LLMToolCall",
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
start = time.time()
result = await _execute_tool(tc.name, tc.arguments, lookup_fn, recall_fn, expand_fn, learn_fn)
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
async def _execute_tool(
tool_name: str,
args: dict[str, Any],
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
) -> dict[str, Any]:
"""Execute a single tool by name."""
if tool_name == "list_mental_models":
return await lookup_fn(None)
elif tool_name == "get_mental_model":
model_id = args.get("model_id")
if not model_id:
return {"error": "get_mental_model requires model_id"}
return await lookup_fn(model_id)
elif tool_name == "recall":
query = args.get("query")
if not query:
return {"error": "recall requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
return await recall_fn(query, max_tokens)
elif tool_name == "learn":
if learn_fn is None:
return {"error": "learn tool is not available"}
name = args.get("name")
description = args.get("description")
if not name or not description:
return {"error": "learn requires name and description"}
return await learn_fn(MentalModelInput(name=name, description=description))
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
if not memory_ids:
return {"error": "expand requires memory_ids"}
depth = args.get("depth", "chunk")
return await expand_fn(memory_ids, depth)
else:
return {"error": f"Unknown tool: {tool_name}"}
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
"""Create a summary of tool input for logging, showing all params."""
if tool_name == "list_mental_models":
return "()"
elif tool_name == "get_mental_model":
return f"(model_id={args.get('model_id', '?')})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
# Show actual value used (default 2048, min 1000)
max_tokens = max(args.get("max_tokens") or 2048, 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "learn":
name = args.get("name", "?")
desc = args.get("description", "")
desc_preview = f"'{desc[:20]}...'" if len(desc) > 20 else f"'{desc}'"
return f"(name='{name}', description={desc_preview})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
depth = args.get("depth", "chunk")
return f"(memory_ids=[{len(memory_ids)} ids], depth={depth})"
elif tool_name == "done":
answer = args.get("answer", "")
answer_preview = f"'{answer[:30]}...'" if len(answer) > 30 else f"'{answer}'"
memory_ids = args.get("memory_ids", [])
model_ids = args.get("model_ids", [])
return f"(answer={answer_preview}, memory_ids={len(memory_ids)}, model_ids={len(model_ids)})"
return str(args)
@@ -0,0 +1,114 @@
"""
Pydantic models for the reflect agent.
"""
from typing import Any, Literal
from pydantic import BaseModel, Field
class MentalModelObservation(BaseModel):
"""An observation within a mental model with its supporting memories."""
title: str = Field(description="Observation header (can be empty for intro)")
text: str = Field(description="Observation content - no headers, use lists/tables/bold")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
class MentalModelInput(BaseModel):
"""Input for the learn tool to create a mental model placeholder.
The agent only specifies name and description - the actual content/observations
are generated during refresh, similar to pinned models.
"""
name: str = Field(description="Human-readable name for the mental model")
description: str = Field(description="What to track - used as prompt for content generation during refresh")
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
class AnswerSection(BaseModel):
"""A section of the answer with its supporting evidence (DEPRECATED)."""
title: str = Field(description="Section header/title")
text: str = Field(description="Section content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
class ReflectAction(BaseModel):
"""Single action the reflect agent can take."""
tool: Literal["list_mental_models", "get_mental_model", "recall", "learn", "expand", "done"] = Field(
description="Tool to invoke: list_mental_models, get_mental_model, recall, learn, expand, or done"
)
# Tool-specific parameters
model_id: str | None = Field(default=None, description="Mental model ID for get_mental_model")
query: str | None = Field(default=None, description="Search query for recall")
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
mental_model: MentalModelInput | None = Field(default=None, description="Mental model to create/update for learn")
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
observations: list[MentalModelObservation] | None = Field(
default=None, description="Observations for done action (when output_mode=observations)"
)
# Plain text answer fields (for output_mode=answer)
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
answer_memory_ids: list[str] | None = Field(
default=None, description="Memory IDs supporting the answer", alias="memory_ids"
)
answer_model_ids: list[str] | None = Field(
default=None, description="Mental model IDs supporting the answer", alias="model_ids"
)
reasoning: str | None = Field(default=None, description="Brief reasoning for this action")
class ReflectActionBatch(BaseModel):
"""Batch of actions for parallel execution."""
actions: list[ReflectAction] = Field(description="List of actions to execute in parallel")
class ToolCall(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
iteration: int = Field(default=0, description="Iteration number (1-based) when this tool was called")
class LLMCall(BaseModel):
"""A single LLM call made during reflect."""
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: int = Field(description="Execution time in milliseconds")
class Observation(BaseModel):
"""A single observation with supporting memories."""
title: str = Field(description="Observation title/header")
text: str = Field(description="Observation content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
class ReflectAgentResult(BaseModel):
"""Result from the reflect agent."""
text: str = Field(description="Final answer text")
observations: list[Observation] = Field(
default_factory=list, description="Structured observations (when output_mode=observations)"
)
structured_output: dict[str, Any] | None = Field(
default=None, description="Structured output parsed according to provided response_schema"
)
iterations: int = Field(default=0, description="Number of iterations taken")
tools_called: int = Field(default=0, description="Total number of tool calls made")
mental_models_created: list[str] = Field(default_factory=list, description="IDs of mental models created/updated")
tool_trace: list[ToolCall] = Field(default_factory=list, description="Trace of all tool calls made")
llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made")
used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
used_model_ids: list[str] = Field(default_factory=list, description="Validated model IDs actually used in answer")
@@ -0,0 +1,312 @@
"""
System prompts for the reflect agent.
"""
import json
from typing import Any
def build_system_prompt_for_tools(
bank_profile: dict[str, Any],
context: str | None = None,
output_mode: str = "answer",
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
This is a simplified prompt since tools are defined separately via the tools parameter.
Args:
bank_profile: Bank profile with name and mission
context: Optional additional context
output_mode: "answer" for plain text response, "observations" for structured observations
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
# Build critical rules based on mode
if output_mode == "observations":
no_info_rule = "- Only say 'I don't have information' AFTER trying recall with no relevant results"
else:
no_info_rule = (
"- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
)
parts = [
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
"",
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST call recall() before saying you don't have information",
no_info_rule,
"",
"## How to Reason",
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
"- Synthesize a coherent narrative from related memories",
"- Be a thoughtful interpreter, not just a literal repeater",
"- When the exact answer isn't stated, use what IS stated to give the best answer",
"",
"## Query Strategy (IMPORTANT)",
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
"",
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
"GOOD: Break it down into component searches:",
" 1. recall('lessons') - find all lesson-related memories",
" 2. recall('teaching sessions') - alternative phrasing",
" 3. recall('student progress') - find student-related memories",
" 4. recall('topics taught') - find subject matter",
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
"- Questions about patterns → search for the individual instances first",
"- Questions comparing things → search for each thing separately",
"- Questions about relationships → search for each party involved",
"",
"## Workflow",
]
# Mode-specific workflow and output format
if output_mode == "observations":
# Observations mode: for mental model generation - no mental model lookup tools
parts.extend(
[
"1. DECOMPOSE the topic into component searches (see Query Strategy above)",
" - Don't search for the topic name itself - search for related concepts",
" - Example for 'Coffee preferences': search 'coffee', 'drinks', 'morning routine', 'caffeine'",
"2. Run multiple recall() calls with varied, targeted queries",
"3. IMPORTANT: Use expand(memory_ids, 'chunk') to verify memories before using them",
" - Always verify the source chunk to confirm the memory is actually relevant",
" - Don't assume a memory is relevant based on the summary alone",
" - Only include memories you've verified via expand()",
"4. When ready, call done() with MULTIPLE structured observations",
"",
"## Output Format: MULTIPLE Structured Observations",
"",
"CRITICAL: You MUST create MULTIPLE separate observations in the array - one for each theme.",
"Do NOT put all content in a single observation!",
"",
"- Create 3-8 separate observations, each as its OWN item in the observations array",
"- Each observation covers ONE specific theme (preferences, history, relationships, etc.)",
"- Each observation has: title (short header), text (content), memory_ids (full UUIDs)",
"",
"Text format for each observation:",
"- Main insight or finding (no markdown headers)",
"- End with 'Key evidence:' section containing DIRECT QUOTES from memories in *italics*",
"- Quote the actual memory text, don't summarize - use *italics* for citations",
"",
"Example done() call with MULTIPLE observations:",
"```json",
"{",
' "observations": [',
" {",
' "title": "Work Preferences",',
' "text": "Prefers async communication and flexible schedules.\\n\\nKey evidence:\\n- *I prefer Slack over calls for most communication*\\n- *Flexible hours help me do my best work*",',
' "memory_ids": ["abc123-full-uuid", "def456-full-uuid"]',
" },",
" {",
' "title": "Technical Background",',
' "text": "Has extensive ML experience spanning a decade.\\n\\nKey evidence:\\n- *I have 10 years of experience in machine learning*\\n- *Led the ML team at my previous company*",',
' "memory_ids": ["ghi789-full-uuid"]',
" }",
" ]",
"}",
"```",
]
)
else:
# Answer mode: include mental model lookup in workflow
parts.extend(
[
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
"2. If relevant, call get_mental_model(model_id) for full observations",
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
" - Identify entities and concepts in the question",
" - Search for each separately with targeted queries",
"4. Run multiple recall() calls - don't just echo the user's question",
"5. Use expand() if you need more context on specific memories",
"6. If you discover an important recurring topic worth tracking, use learn() to create a mental model",
"7. When ready, call done() with your answer and supporting memory_ids",
"",
"## When to Use learn()",
"Use learn() to create a new mental model when you discover:",
"- A person, project, or concept that appears frequently in memories",
"- An important topic the user seems to care about but has no mental model for",
"- A pattern or relationship worth synthesizing for future reference",
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put memory IDs ONLY in the memory_ids array parameter, not in the answer",
]
)
parts.append("")
parts.append(f"## Memory Bank: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
if context:
parts.append(f"\n## Additional Context\n{context}")
return "\n".join(parts)
def build_agent_prompt(
query: str,
context_history: list[dict],
bank_profile: dict,
additional_context: str | None = None,
) -> str:
"""Build the user prompt for the reflect agent."""
parts = []
# Bank identity
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts.append(f"## Memory Bank Context\nName: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits if present
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
# Additional context from caller
if additional_context:
parts.append(f"\n## Additional Context\n{additional_context}")
# Tool call history
if context_history:
parts.append("\n## Tool Results (synthesize and reason from this data)")
for i, entry in enumerate(context_history, 1):
tool = entry["tool"]
output = entry["output"]
# Format as proper JSON for LLM readability
try:
output_str = json.dumps(output, indent=2, default=str)
except (TypeError, ValueError):
output_str = str(output)
parts.append(f"\n### Call {i}: {tool}\n```json\n{output_str}\n```")
# The question
parts.append(f"\n## Question\n{query}")
# Instructions
if context_history:
parts.append(
"\n## Instructions\n"
"Based on the tool results above, either call more tools or provide your final answer. "
"Synthesize and reason from the data - make reasonable inferences when helpful. "
"If you have related information, use it to give the best possible answer."
)
else:
parts.append(
"\n## Instructions\n"
"Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. "
"If a relevant model exists, use get_mental_model(model_id) to get its observations. "
"Then use recall(query) for specific details not covered by mental models."
)
return "\n".join(parts)
def build_final_prompt(
query: str,
context_history: list[dict],
bank_profile: dict,
additional_context: str | None = None,
) -> str:
"""Build the final prompt when forcing a text response (no tools)."""
parts = []
# Bank identity
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts.append(f"## Memory Bank Context\nName: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits if present
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
# Additional context from caller
if additional_context:
parts.append(f"\n## Additional Context\n{additional_context}")
# Tool call history
if context_history:
parts.append("\n## Retrieved Data (synthesize and reason from this data)")
for entry in context_history:
tool = entry["tool"]
output = entry["output"]
# Format as proper JSON for LLM readability
try:
output_str = json.dumps(output, indent=2, default=str)
except (TypeError, ValueError):
output_str = str(output)
parts.append(f"\n### From {tool}:\n```json\n{output_str}\n```")
else:
parts.append("\n## Retrieved Data\nNo data was retrieved.")
# The question
parts.append(f"\n## Question\n{query}")
# Final instructions
parts.append(
"\n## Instructions\n"
"Provide a thoughtful answer by synthesizing and reasoning from the retrieved data above. "
"You can make reasonable inferences from the memories, but don't completely fabricate information."
"If the exact answer isn't stated, use what IS stated to give the best possible answer. "
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question."
)
return "\n".join(parts)
FINAL_SYSTEM_PROMPT = """You are a thoughtful assistant that synthesizes answers from retrieved memories.
Your approach:
- Reason over the retrieved memories to answer the question
- Make reasonable inferences when the exact answer isn't explicitly stated
- Connect related memories to form a complete picture
- Be helpful - if you have related information, use it to give the best possible answer
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
Do NOT fabricate information that has no basis in the retrieved data."""
@@ -0,0 +1,425 @@
"""
Tool implementations for the reflect agent.
"""
import logging
import re
import uuid
from typing import TYPE_CHECKING, Any
from .models import MentalModelInput
if TYPE_CHECKING:
from asyncpg import Connection
from ...api.http import RequestContext
from ..memory_engine import MemoryEngine
logger = logging.getLogger(__name__)
def generate_model_id(name: str) -> str:
"""Generate a stable ID from mental model name."""
# Normalize: lowercase, replace spaces/special chars with hyphens
normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# Truncate to reasonable length
return normalized[:50]
async def tool_lookup(
conn: "Connection",
bank_id: str,
model_id: str | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
) -> dict[str, Any]:
"""
List or get mental models.
Args:
conn: Database connection
bank_id: Bank identifier
model_id: Optional specific model ID to get (if None, lists all)
tags: Optional tags to filter models (when listing)
tags_match: How to match tags - "any" (OR), "all" (AND)
Returns:
Dict with either a list of models or a single model's details
"""
if model_id:
# Get specific mental model with full details including observations
row = await conn.fetchrow(
"""
SELECT id, subtype, name, description, observations, entity_id, last_updated
FROM mental_models
WHERE id = $1 AND bank_id = $2
""",
model_id,
bank_id,
)
if row:
# Parse observations JSON
obs_data = row["observations"] or {"observations": []}
if isinstance(obs_data, str):
import json
obs_data = json.loads(obs_data)
observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data
# Normalize observation format: map memory_ids/fact_ids to based_on
observations = []
for obs in observations_raw:
if isinstance(obs, dict):
based_on = obs.get("memory_ids") or obs.get("fact_ids") or []
observations.append(
{
"title": obs.get("title", ""),
"text": obs.get("text", ""),
"based_on": based_on,
}
)
return {
"found": True,
"model": {
"id": row["id"],
"subtype": row["subtype"],
"name": row["name"],
"description": row["description"],
"observations": observations, # [{title, text, based_on}, ...]
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
},
}
return {"found": False, "model_id": model_id}
else:
# List mental models (compact: id, name, description only)
# Full observations are retrieved via get_mental_model(model_id)
# Filter by tags if provided
if tags:
if tags_match == "all":
# All tags must match
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND tags @> $2::varchar[]
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
tags,
)
else:
# Any tag matches (OR) - default
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND tags && $2::varchar[]
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
tags,
)
else:
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
)
return {
"count": len(rows),
"models": [
{
"id": row["id"],
"subtype": row["subtype"],
"name": row["name"],
"description": row["description"],
}
for row in rows
],
}
async def tool_recall(
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
max_tokens: int = 2048,
max_results: int = 50,
tags: list[str] | None = None,
tags_match: str = "any",
connection_budget: int = 1,
) -> dict[str, Any]:
"""
Search memories using TEMPR retrieval.
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
query: Search query
request_context: Request context for authentication
max_tokens: Maximum tokens for results (default 2048)
max_results: Maximum number of results
tags: Filter by tags (includes untagged memories)
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)
Returns:
Dict with list of matching memories
"""
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"], # Exclude opinions
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
tags=tags,
tags_match=tags_match,
_connection_budget=connection_budget,
)
memories = []
for m in result.results[:max_results]:
memories.append(
{
"id": str(m.id),
"text": m.text,
"type": m.fact_type,
"entities": m.entities or [],
"occurred": m.occurred_start, # Already ISO format string
}
)
return {
"query": query,
"count": len(memories),
"memories": memories,
}
async def tool_learn(
conn: "Connection",
bank_id: str,
input: MentalModelInput,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Create a mental model placeholder with subtype='learned'.
The agent only specifies name and description - actual observations are generated
in the background via refresh, similar to pinned models.
Args:
conn: Database connection
bank_id: Bank identifier
input: Mental model input data (name, description, optional entity_id)
tags: Tags to apply to new mental models (from reflect context)
Returns:
Dict with created model info including model_id for background generation
"""
model_id = generate_model_id(input.name)
# Parse entity_id if provided
entity_uuid = None
if input.entity_id:
try:
entity_uuid = uuid.UUID(input.entity_id)
except ValueError:
logger.warning(f"Invalid entity_id format: {input.entity_id}")
# Check if model exists
existing = await conn.fetchrow(
"SELECT id FROM mental_models WHERE id = $1 AND bank_id = $2",
model_id,
bank_id,
)
if existing:
# Update description only - observations will be regenerated
await conn.execute(
"""
UPDATE mental_models SET
description = $3,
entity_id = $4
WHERE id = $1 AND bank_id = $2
""",
model_id,
bank_id,
input.description,
entity_uuid,
)
status = "updated"
else:
# Insert new model placeholder - observations will be generated in background
await conn.execute(
"""
INSERT INTO mental_models (id, bank_id, subtype, name, description, observations, entity_id, tags, created_at)
VALUES ($1, $2, 'learned', $3, $4, '{}'::jsonb, $5, $6, NOW())
""",
model_id,
bank_id,
input.name,
input.description,
entity_uuid,
tags or [],
)
status = "created"
logger.info(f"[REFLECT] Mental model '{model_id}' {status} in bank {bank_id} - pending background generation")
return {
"status": status,
"model_id": model_id,
"name": input.name,
"pending_generation": True,
}
async def tool_expand(
conn: "Connection",
bank_id: str,
memory_ids: list[str],
depth: str,
) -> dict[str, Any]:
"""
Expand multiple memories to get chunk or document context.
Args:
conn: Database connection
bank_id: Bank identifier
memory_ids: List of memory unit IDs
depth: "chunk" or "document"
Returns:
Dict with results array, each containing memory, chunk, and optionally document data
"""
if not memory_ids:
return {"error": "memory_ids is required and must not be empty"}
# Validate and convert UUIDs
valid_uuids: list[uuid.UUID] = []
errors: dict[str, str] = {}
for mid in memory_ids:
try:
valid_uuids.append(uuid.UUID(mid))
except ValueError:
errors[mid] = f"Invalid memory_id format: {mid}"
if not valid_uuids:
return {"error": "No valid memory IDs provided", "details": errors}
# Batch fetch all memory units
memories = await conn.fetch(
"""
SELECT id, text, chunk_id, document_id, fact_type, context
FROM memory_units
WHERE id = ANY($1) AND bank_id = $2
""",
valid_uuids,
bank_id,
)
memory_map = {row["id"]: row for row in memories}
# Collect chunk_ids and document_ids for batch fetching
chunk_ids = [m["chunk_id"] for m in memories if m["chunk_id"]]
doc_ids_from_chunks: set[str] = set()
doc_ids_direct: set[str] = set()
# Batch fetch all chunks
chunk_map: dict[str, Any] = {}
if chunk_ids:
chunks = await conn.fetch(
"""
SELECT chunk_id, chunk_text, chunk_index, document_id
FROM chunks
WHERE chunk_id = ANY($1)
""",
chunk_ids,
)
chunk_map = {row["chunk_id"]: row for row in chunks}
if depth == "document":
doc_ids_from_chunks = {c["document_id"] for c in chunks if c["document_id"]}
# Collect direct document IDs (memories without chunks)
if depth == "document":
for m in memories:
if not m["chunk_id"] and m["document_id"]:
doc_ids_direct.add(m["document_id"])
# Batch fetch all documents
doc_map: dict[str, Any] = {}
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
if all_doc_ids:
docs = await conn.fetch(
"""
SELECT id, original_text, metadata, retain_params
FROM documents
WHERE id = ANY($1) AND bank_id = $2
""",
all_doc_ids,
bank_id,
)
doc_map = {row["id"]: row for row in docs}
# Build results
results: list[dict[str, Any]] = []
for mid, mem_uuid in zip(memory_ids, valid_uuids):
if mid in errors:
results.append({"memory_id": mid, "error": errors[mid]})
continue
memory = memory_map.get(mem_uuid)
if not memory:
results.append({"memory_id": mid, "error": f"Memory not found: {mid}"})
continue
item: dict[str, Any] = {
"memory_id": mid,
"memory": {
"id": str(memory["id"]),
"text": memory["text"],
"type": memory["fact_type"],
"context": memory["context"],
},
}
# Add chunk if available
if memory["chunk_id"] and memory["chunk_id"] in chunk_map:
chunk = chunk_map[memory["chunk_id"]]
item["chunk"] = {
"id": chunk["chunk_id"],
"text": chunk["chunk_text"],
"index": chunk["chunk_index"],
"document_id": chunk["document_id"],
}
# Add document if depth=document
if depth == "document" and chunk["document_id"] in doc_map:
doc = doc_map[chunk["document_id"]]
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"retain_params": doc["retain_params"],
}
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
# No chunk, but has document_id
doc = doc_map[memory["document_id"]]
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"retain_params": doc["retain_params"],
}
results.append(item)
return {"results": results, "count": len(results)}
@@ -0,0 +1,212 @@
"""
Tool schema definitions for the reflect agent.
These are OpenAI-format tool definitions used with native tool calling.
"""
from typing import Literal
# Tool definitions in OpenAI format
TOOL_LIST_MENTAL_MODELS = {
"type": "function",
"function": {
"name": "list_mental_models",
"description": "List all available mental models - your synthesized knowledge about entities, concepts, and events. Returns an array of models with id, name, and description.",
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
}
TOOL_GET_MENTAL_MODEL = {
"type": "function",
"function": {
"name": "get_mental_model",
"description": "Get full details of a specific mental model including all observations and memory references.",
"parameters": {
"type": "object",
"properties": {
"model_id": {
"type": "string",
"description": "ID of the mental model (from list_mental_models results)",
},
},
"required": ["model_id"],
},
},
}
TOOL_RECALL = {
"type": "function",
"function": {
"name": "recall",
"description": "Search memories using semantic + temporal retrieval. Returns relevant memories from experience and world knowledge, each with an 'id' you can reference.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query string",
},
"max_tokens": {
"type": "integer",
"description": "Optional limit on result size (default 2048). Use higher values for broader searches.",
},
},
"required": ["query"],
},
},
}
TOOL_LEARN = {
"type": "function",
"function": {
"name": "learn",
"description": "Create a new mental model to track an important recurring topic. Use when you discover a person, project, concept, or pattern that appears frequently and would benefit from synthesized knowledge. The model content will be generated automatically.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable name (e.g., 'Project Alpha', 'John Smith', 'Product Strategy')",
},
"description": {
"type": "string",
"description": "What to track and synthesize (e.g., 'Track goals, milestones, blockers, and key decisions for Project Alpha')",
},
},
"required": ["name", "description"],
},
},
}
TOOL_EXPAND = {
"type": "function",
"function": {
"name": "expand",
"description": "Get more context for one or more memories. Memory hierarchy: memory -> chunk -> document.",
"parameters": {
"type": "object",
"properties": {
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of memory IDs from recall results (batch multiple for efficiency)",
},
"depth": {
"type": "string",
"enum": ["chunk", "document"],
"description": "chunk: surrounding text chunk, document: full source document",
},
},
"required": ["memory_ids", "depth"],
},
},
}
TOOL_DONE_ANSWER = {
"type": "function",
"function": {
"name": "done",
"description": "Signal completion with your final answer. Use this when you have gathered enough information to answer the question.",
"parameters": {
"type": "object",
"properties": {
"answer": {
"type": "string",
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
},
},
"required": ["answer"],
},
},
}
TOOL_DONE_OBSERVATIONS = {
"type": "function",
"function": {
"name": "done",
"description": "Signal completion with MULTIPLE structured observations. Each observation must be a SEPARATE item in the array covering ONE theme. Do NOT combine all content into a single observation.",
"parameters": {
"type": "object",
"properties": {
"observations": {
"type": "array",
"minItems": 3,
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short header for this observation's theme (e.g., 'Work Style', 'Technical Skills')",
},
"text": {
"type": "string",
"description": "Observation content about ONE theme. End with 'Key evidence:' containing text citations (summaries of what memories say), NOT memory IDs.",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Full UUIDs of memories supporting this observation (put IDs here, not in text)",
},
},
"required": ["title", "text", "memory_ids"],
},
"description": "Array of 3-8 observations, each covering a DIFFERENT aspect/theme. Do NOT put everything in one observation.",
},
},
"required": ["observations"],
},
},
}
def get_reflect_tools(
enable_learn: bool = True, output_mode: Literal["answer", "observations"] = "answer"
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
Args:
enable_learn: Whether to include the learn tool
output_mode: "answer" or "observations" - determines done tool format
In observations mode, mental model tools are excluded to avoid
using potentially outdated models during regeneration.
Returns:
List of tool definitions in OpenAI format
"""
tools = []
# In answer mode, include mental model tools for lookup
# In observations mode (mental model generation), exclude them to avoid circular references
if output_mode == "answer":
tools.append(TOOL_LIST_MENTAL_MODELS)
tools.append(TOOL_GET_MENTAL_MODEL)
tools.append(TOOL_RECALL)
if enable_learn:
tools.append(TOOL_LEARN)
tools.append(TOOL_EXPAND)
# Add appropriate done tool based on output mode
if output_mode == "observations":
tools.append(TOOL_DONE_OBSERVATIONS)
else:
tools.append(TOOL_DONE_ANSWER)
return tools
@@ -10,8 +10,52 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'observation' which is internal)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience"])
class LLMToolCall(BaseModel):
"""A tool call requested by the LLM."""
id: str = Field(description="Unique identifier for this tool call")
name: str = Field(description="Name of the tool to call")
arguments: dict[str, Any] = Field(description="Arguments to pass to the tool")
class LLMToolCallResult(BaseModel):
"""Result from an LLM call that may include tool calls."""
content: str | None = Field(default=None, description="Text content if any")
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
class ToolCallTrace(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
iteration: int = Field(default=0, description="Iteration number (1-based) when this tool was called")
class LLMCallTrace(BaseModel):
"""A single LLM call made during reflect."""
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: int = Field(description="Execution time in milliseconds")
class MentalModelRef(BaseModel):
"""Reference to a mental model accessed during reflect."""
id: str = Field(description="Mental model ID")
name: str = Field(description="Mental model name")
type: str = Field(description="Mental model type: entity, concept, event")
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
description: str = Field(description="Brief description")
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
class TokenUsage(BaseModel):
@@ -198,6 +242,18 @@ class ReflectResult(BaseModel):
default=None,
description="Token usage metrics for the LLM calls made during this reflect operation.",
)
tool_trace: list[ToolCallTrace] = Field(
default_factory=list,
description="Trace of tool calls made during reflection. Only present when include.tool_calls is enabled.",
)
llm_trace: list[LLMCallTrace] = Field(
default_factory=list,
description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.",
)
mental_models: list[MentalModelRef] = Field(
default_factory=list,
description="Mental models accessed during reflection. Only present when include.facts is enabled.",
)
class Opinion(BaseModel):
@@ -261,3 +317,32 @@ class EntityState(BaseModel):
observations: list[EntityObservation] = Field(
default_factory=list, description="List of observations about this entity"
)
class MentalModel(BaseModel):
"""
A manually configured mental model for tracking specific topics/areas.
Mental models are user-defined focus areas that the agent should track
and maintain summaries for, unlike auto-extracted entities.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "team-dynamics",
"name": "Team Dynamics",
"description": "Track how the team collaborates, communication patterns, conflicts, and resolutions",
"summary": "The team has strong collaboration...",
"summary_updated_at": "2024-01-15T10:30:00Z",
"created_at": "2024-01-10T08:00:00Z",
}
}
)
id: str = Field(description="Unique identifier (alphanumeric lowercase)")
name: str = Field(description="Display name for the mental model")
description: str = Field(description="Prompt/directions for what to track and summarize")
summary: str | None = Field(None, description="Generated summary based on relevant facts")
summary_updated_at: str | None = Field(None, description="ISO format date when summary was last updated")
created_at: str = Field(description="ISO format date when the mental model was created")
@@ -1,5 +1,5 @@
"""
bank profile utilities for disposition and background management.
bank profile utilities for disposition and mission management.
"""
import json
@@ -27,19 +27,18 @@ class BankProfile(TypedDict):
name: str
disposition: DispositionTraits
background: str
mission: str
class BackgroundMergeResponse(BaseModel):
"""LLM response for background merge with disposition inference."""
class MissionMergeResponse(BaseModel):
"""LLM response for mission merge."""
background: str = Field(description="Merged background in first person perspective")
disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)")
mission: str = Field(description="Merged mission in first person perspective")
async def get_bank_profile(pool, bank_id: str) -> BankProfile:
"""
Get bank profile (name, disposition + background).
Get bank profile (name, disposition + mission).
Auto-creates bank with default values if not exists.
Args:
@@ -47,13 +46,13 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
bank_id: bank IDentifier
Returns:
BankProfile with name, typed DispositionTraits, and background
BankProfile with name, typed DispositionTraits, and mission
"""
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
f"""
SELECT name, disposition, background
SELECT name, disposition, mission
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
@@ -66,13 +65,15 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
disposition_data = json.loads(disposition_data)
return BankProfile(
name=row["name"], disposition=DispositionTraits(**disposition_data), background=row["background"]
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
)
# Bank doesn't exist, create with defaults
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, background)
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission)
VALUES ($1, $2, $3::jsonb, $4)
ON CONFLICT (bank_id) DO NOTHING
""",
@@ -82,7 +83,7 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
"",
)
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), background="")
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission="")
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
@@ -110,244 +111,121 @@ async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int
)
async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, update_disposition: bool = True) -> dict:
async def set_bank_mission(pool, bank_id: str, mission: str) -> None:
"""
Merge new background information with existing background using LLM.
Normalizes to first person ("I") and resolves conflicts.
Optionally infers disposition traits from the merged background.
Set bank mission (replacing any existing mission).
Args:
pool: Database connection pool
llm_config: LLM configuration for background merging
bank_id: bank IDentifier
new_info: New background information to add/merge
update_disposition: If True, infer Big Five traits from background (default: True)
mission: The mission text
"""
# Ensure bank exists first
await get_bank_profile(pool, bank_id)
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET mission = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
mission,
)
async def merge_bank_mission(pool, llm_config, bank_id: str, new_info: str) -> dict:
"""
Merge new mission information with existing mission using LLM.
Normalizes to first person ("I") and resolves conflicts.
Args:
pool: Database connection pool
llm_config: LLM configuration for mission merging
bank_id: bank IDentifier
new_info: New mission information to add/merge
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'mission' (str) key
"""
# Get current profile
profile = await get_bank_profile(pool, bank_id)
current_background = profile["background"]
current_mission = profile["mission"]
# Use LLM to merge backgrounds and optionally infer disposition
result = await _llm_merge_background(llm_config, current_background, new_info, infer_disposition=update_disposition)
# Use LLM to merge missions
result = await _llm_merge_mission(llm_config, current_mission, new_info)
merged_background = result["background"]
inferred_disposition = result.get("disposition")
merged_mission = result["mission"]
# Update in database
async with acquire_with_retry(pool) as conn:
if inferred_disposition:
# Update both background and disposition
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET background = $2,
disposition = $3::jsonb,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background,
json.dumps(inferred_disposition),
)
else:
# Update only background
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background,
)
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET mission = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_mission,
)
response = {"background": merged_background}
if inferred_disposition:
response["disposition"] = inferred_disposition
return response
return {"mission": merged_mission}
async def _llm_merge_background(llm_config, current: str, new_info: str, infer_disposition: bool = False) -> dict:
async def _llm_merge_mission(llm_config, current: str, new_info: str) -> dict:
"""
Use LLM to intelligently merge background information.
Optionally infer Big Five disposition traits from the merged background.
Use LLM to intelligently merge mission information.
Args:
llm_config: LLM configuration to use
current: Current background text
current: Current mission text
new_info: New information to merge
infer_disposition: If True, also infer disposition traits
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'mission' (str) key
"""
if infer_disposition:
prompt = f"""You are helping maintain a memory bank's background/profile and infer their disposition. You MUST respond with ONLY valid JSON.
prompt = f"""You are helping maintain an agent's mission statement.
Current background: {current if current else "(empty)"}
Current mission: {current if current else "(empty)"}
New information to add: {new_info}
Instructions:
1. Merge the new information with the current background
2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old
3. Keep additions that don't conflict
4. Output in FIRST PERSON ("I") perspective
5. Be concise - keep merged background under 500 characters
6. Infer disposition traits from the merged background (each 1-5 integer):
- Skepticism: 1-5 (1=trusting, takes things at face value; 5=skeptical, questions everything)
- Literalism: 1-5 (1=flexible interpretation, reads between lines; 5=literal, exact interpretation)
- Empathy: 1-5 (1=detached, focuses on facts; 5=empathetic, considers emotional context)
CRITICAL: You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations. Just the JSON.
Format:
{{
"background": "the merged background text in first person",
"disposition": {{
"skepticism": 3,
"literalism": 3,
"empathy": 3
}}
}}
Trait inference examples:
- "I'm a lawyer" → skepticism: 4, literalism: 5, empathy: 2
- "I'm a therapist" → skepticism: 2, literalism: 2, empathy: 5
- "I'm an engineer" → skepticism: 3, literalism: 4, empathy: 3
- "I've been burned before by trusting people" → skepticism: 5, literalism: 3, empathy: 3
- "I try to understand what people really mean" → skepticism: 3, literalism: 2, empathy: 4
- "I take contracts very seriously" → skepticism: 4, literalism: 5, empathy: 2"""
else:
prompt = f"""You are helping maintain a memory bank's background/profile.
Current background: {current if current else "(empty)"}
New information to add: {new_info}
Instructions:
1. Merge the new information with the current background
2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old
1. Merge the new information with the current mission
2. If there are conflicts, the NEW information overwrites the old
3. Keep additions that don't conflict
4. Output in FIRST PERSON ("I") perspective
5. Be concise - keep it under 500 characters
6. Return ONLY the merged background text, no explanations
6. Return ONLY the merged mission text, no explanations
Merged background:"""
Merged mission:"""
try:
# Prepare messages
messages = [{"role": "user", "content": prompt}]
if infer_disposition:
# Use structured output with Pydantic model for disposition inference
try:
parsed = await llm_config.call(
messages=messages,
response_format=BackgroundMergeResponse,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192,
)
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
# Convert Pydantic model to dict format
return {"background": parsed.background, "disposition": parsed.disposition.model_dump()}
except Exception as e:
logger.warning(f"Structured output failed, falling back to manual parsing: {e}")
# Fall through to manual parsing below
# Manual parsing fallback or non-disposition merge
content = await llm_config.call(
messages=messages, scope="bank_background", temperature=0.3, max_completion_tokens=8192
messages=messages, scope="bank_mission", temperature=0.3, max_completion_tokens=8192
)
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
logger.info(f"LLM response for mission merge (first 500 chars): {content[:500]}")
if infer_disposition:
# Parse JSON response - try multiple extraction methods
result = None
# Method 1: Direct parse
try:
result = json.loads(content)
logger.info("Successfully parsed JSON directly")
except json.JSONDecodeError:
pass
# Method 2: Extract from markdown code blocks
if result is None:
# Remove markdown code blocks
code_block_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
if code_block_match:
try:
result = json.loads(code_block_match.group(1))
logger.info("Successfully extracted JSON from markdown code block")
except json.JSONDecodeError:
pass
# Method 3: Find nested JSON structure
if result is None:
# Look for JSON object with nested structure
json_match = re.search(
r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL
)
if json_match:
try:
result = json.loads(json_match.group())
logger.info("Successfully extracted JSON using nested pattern")
except json.JSONDecodeError:
pass
# All parsing methods failed - use fallback
if result is None:
logger.warning(f"Failed to extract JSON from LLM response. Raw content: {content[:200]}")
# Fallback: use new_info as background with default disposition
return {
"background": new_info if new_info else current if current else "",
"disposition": DEFAULT_DISPOSITION.copy(),
}
# Validate disposition values
disposition = result.get("disposition", {})
for key in ["skepticism", "literalism", "empathy"]:
if key not in disposition:
disposition[key] = 3 # Default to neutral
else:
# Clamp to [1, 5] and convert to int
disposition[key] = max(1, min(5, int(disposition[key])))
result["disposition"] = disposition
# Ensure background exists
if "background" not in result or not result["background"]:
result["background"] = new_info if new_info else ""
return result
else:
# Just background merge
merged = content
if not merged or merged.lower() in ["(empty)", "none", "n/a"]:
merged = new_info if new_info else ""
return {"background": merged}
merged = content.strip()
if not merged or merged.lower() in ["(empty)", "none", "n/a"]:
merged = new_info if new_info else ""
return {"mission": merged}
except Exception as e:
logger.error(f"Error merging background with LLM: {e}")
logger.error(f"Error merging mission with LLM: {e}")
# Fallback: just append new info
if current:
merged = f"{current} {new_info}".strip()
else:
merged = new_info
result = {"background": merged}
if infer_disposition:
result["disposition"] = DEFAULT_DISPOSITION.copy()
return result
return {"mission": merged}
async def list_banks(pool) -> list:
@@ -358,12 +236,12 @@ async def list_banks(pool) -> list:
pool: Database connection pool
Returns:
List of dicts with bank_id, name, disposition, background, created_at, updated_at
List of dicts with bank_id, name, disposition, mission, created_at, updated_at
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, name, disposition, background, created_at, updated_at
SELECT bank_id, name, disposition, mission, created_at, updated_at
FROM {fq_table("banks")}
ORDER BY updated_at DESC
"""
@@ -381,7 +259,7 @@ async def list_banks(pool) -> list:
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"background": row["background"],
"mission": row["mission"] or "",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
@@ -126,7 +126,7 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
"""
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, disposition, background)
INSERT INTO {fq_table("banks")} (bank_id, disposition, mission)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (bank_id) DO UPDATE
SET updated_at = NOW()
@@ -1,254 +0,0 @@
"""
Observation regeneration for retain pipeline.
Regenerates entity observations as part of the retain transaction.
"""
import logging
import time
import uuid
from datetime import UTC, datetime
from ...config import get_config
from ..memory_engine import fq_table
from ..search import observation_utils
from . import embedding_utils
from .types import EntityLink
logger = logging.getLogger(__name__)
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
# Simple dataclass-like container for facts (avoid importing from memory_engine)
class MemoryFactForObservation:
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: str | None):
self.id = id
self.text = text
self.fact_type = fact_type
self.context = context
self.occurred_start = occurred_start
async def regenerate_observations_batch(
conn, embeddings_model, llm_config, bank_id: str, entity_links: list[EntityLink], log_buffer: list[str] = None
) -> None:
"""
Regenerate observations for top entities in this batch.
Called INSIDE the retain transaction for atomicity - if observations
fail, the entire retain batch is rolled back.
Args:
conn: Database connection (from the retain transaction)
embeddings_model: Embeddings model for generating observation embeddings
llm_config: LLM configuration for observation extraction
bank_id: Bank identifier
entity_links: Entity links from this batch
log_buffer: Optional log buffer for timing
"""
config = get_config()
TOP_N_ENTITIES = config.observation_top_entities
MIN_FACTS_THRESHOLD = config.observation_min_facts
if not entity_links:
return
# Count mentions per entity in this batch
entity_mention_counts: dict[str, int] = {}
for link in entity_links:
if link.entity_id:
entity_id = str(link.entity_id)
entity_mention_counts[entity_id] = entity_mention_counts.get(entity_id, 0) + 1
if not entity_mention_counts:
return
# Sort by mention count descending and take top N
sorted_entities = sorted(entity_mention_counts.items(), key=lambda x: x[1], reverse=True)
entities_to_process = [e[0] for e in sorted_entities[:TOP_N_ENTITIES]]
obs_start = time.time()
# Convert to UUIDs
entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entities_to_process]
# Batch query for entity names
entity_rows = await conn.fetch(
f"""
SELECT id, canonical_name FROM {fq_table("entities")}
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids,
bank_id,
)
entity_names = {row["id"]: row["canonical_name"] for row in entity_rows}
# Batch query for fact counts
fact_counts = await conn.fetch(
f"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids,
bank_id,
)
entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts}
# Filter entities that meet the threshold
entities_with_names = []
for entity_id in entities_to_process:
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
if entity_uuid not in entity_names:
continue
fact_count = entity_fact_counts.get(entity_uuid, 0)
if fact_count >= MIN_FACTS_THRESHOLD:
entities_with_names.append((entity_id, entity_names[entity_uuid]))
if not entities_with_names:
return
# Process entities SEQUENTIALLY (asyncpg doesn't allow concurrent queries on same connection)
# We must use the same connection to stay in the retain transaction
total_observations = 0
for entity_id, entity_name in entities_with_names:
try:
obs_ids = await _regenerate_entity_observations(
conn, embeddings_model, llm_config, bank_id, entity_id, entity_name
)
total_observations += len(obs_ids)
except Exception as e:
logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}")
obs_time = time.time() - obs_start
if log_buffer is not None:
log_buffer.append(
f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s"
)
async def _regenerate_entity_observations(
conn, embeddings_model, llm_config, bank_id: str, entity_id: str, entity_name: str
) -> list[str]:
"""
Regenerate observations for a single entity.
Uses the provided connection (part of retain transaction).
Args:
conn: Database connection (from the retain transaction)
embeddings_model: Embeddings model
llm_config: LLM configuration
bank_id: Bank identifier
entity_id: Entity UUID
entity_name: Canonical name of the entity
Returns:
List of created observation IDs
"""
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
# Get all facts mentioning this entity (exclude observations themselves)
rows = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'experience')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id,
entity_uuid,
)
if not rows:
return []
# Convert to fact objects for observation extraction
facts = []
for row in rows:
occurred_start = row["occurred_start"].isoformat() if row["occurred_start"] else None
facts.append(
MemoryFactForObservation(
id=str(row["id"]),
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
occurred_start=occurred_start,
)
)
# Extract observations using LLM
observations = await observation_utils.extract_observations_from_facts(llm_config, entity_name, facts)
if not observations:
return []
# Delete old observations for this entity
await conn.execute(
f"""
DELETE FROM {fq_table("memory_units")}
WHERE id IN (
SELECT mu.id
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
""",
bank_id,
entity_uuid,
)
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, observations)
# Insert new observations
current_time = utcnow()
created_ids = []
for obs_text, embedding in zip(observations, embeddings):
result = await conn.fetchrow(
f"""
INSERT INTO {fq_table("memory_units")} (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0)
RETURNING id
""",
bank_id,
obs_text,
str(embedding),
f"observation about {entity_name}",
current_time,
current_time,
current_time,
current_time,
)
obs_id = str(result["id"])
created_ids.append(obs_id)
# Link observation to entity
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id),
entity_uuid,
)
return created_ids
@@ -9,7 +9,6 @@ import time
import uuid
from datetime import UTC, datetime
from ...config import get_config
from ..db_utils import acquire_with_retry
from . import bank_utils
@@ -28,9 +27,8 @@ from . import (
fact_extraction,
fact_storage,
link_creation,
observation_regeneration,
)
from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
from .types import EntityLink, ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
logger = logging.getLogger(__name__)
@@ -40,7 +38,6 @@ async def retain_batch(
embeddings_model,
llm_config,
entity_resolver,
task_backend,
format_date_fn,
duplicate_checker_fn,
bank_id: str,
@@ -59,7 +56,6 @@ async def retain_batch(
embeddings_model: Embeddings model for generating embeddings
llm_config: LLM configuration for fact extraction
entity_resolver: Entity resolver for entity processing
task_backend: Task backend for background jobs
format_date_fn: Function to format datetime to readable string
duplicate_checker_fn: Function to check for duplicate facts
bank_id: Bank identifier
@@ -408,27 +404,9 @@ async def retain_batch(
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Regenerate observations - sync (in transaction) or async (background task)
config = get_config()
if config.retain_observations_async:
# Queue for async processing after transaction commits
entity_ids_for_async = list(set(link.entity_id for link in entity_links)) if entity_links else []
log_buffer.append(
f"[11] Observations: queued {len(entity_ids_for_async)} entities for async processing"
)
else:
# Run synchronously inside transaction for atomicity
await observation_regeneration.regenerate_observations_batch(
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
)
entity_ids_for_async = []
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
# Trigger background tasks AFTER transaction commits
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts, entity_ids_for_async)
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'=' * 60}")
@@ -470,35 +448,3 @@ def _map_results_to_contents(
result_unit_ids.append(content_unit_ids)
return result_unit_ids
async def _trigger_background_tasks(
task_backend,
bank_id: str,
unit_ids: list[str],
facts: list[ProcessedFact],
entity_ids_for_observations: list[str] | None = None,
) -> None:
"""Trigger background tasks after transaction commits."""
# Trigger opinion reinforcement if there are entities
fact_entities = [[e.name for e in fact.entities] for fact in facts]
if any(fact_entities):
await task_backend.submit_task(
{
"type": "reinforce_opinion",
"bank_id": bank_id,
"created_unit_ids": unit_ids,
"unit_texts": [fact.fact_text for fact in facts],
"unit_entities": fact_entities,
}
)
# Trigger observation regeneration if async mode is enabled
if entity_ids_for_observations:
await task_backend.submit_task(
{
"type": "regenerate_observations",
"bank_id": bank_id,
"entity_ids": entity_ids_for_observations,
}
)
@@ -1,125 +0,0 @@
"""
Observation utilities for generating entity observations from facts.
Observations are objective facts synthesized from multiple memory facts
about an entity, without personality influence.
"""
import logging
from pydantic import BaseModel, Field
from ..response_models import MemoryFact
logger = logging.getLogger(__name__)
class Observation(BaseModel):
"""An observation about an entity."""
observation: str = Field(description="The observation text - a factual statement about the entity")
class ObservationExtractionResponse(BaseModel):
"""Response containing extracted observations."""
observations: list[Observation] = Field(default_factory=list, description="List of observations about the entity")
def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str:
"""Format facts as text for observation extraction prompt."""
import json
if not facts:
return "[]"
formatted = []
for fact in facts:
fact_obj = {"text": fact.text}
# Add context if available
if fact.context:
fact_obj["context"] = fact.context
# Add occurred_start if available
if fact.occurred_start:
fact_obj["occurred_at"] = fact.occurred_start
formatted.append(fact_obj)
return json.dumps(formatted, indent=2)
def build_observation_prompt(
entity_name: str,
facts_text: str,
) -> str:
"""Build the observation extraction prompt for the LLM."""
return f"""Based on the following facts about "{entity_name}", generate a list of key observations.
FACTS ABOUT {entity_name.upper()}:
{facts_text}
Your task: Synthesize the facts into clear, objective observations about {entity_name}.
GUIDELINES:
1. Each observation should be a factual statement about {entity_name}
2. Combine related facts into single observations where appropriate
3. Be objective - do not add opinions, judgments, or interpretations
4. Focus on what we KNOW about {entity_name}, not what we assume
5. Include observations about: identity, characteristics, roles, relationships, activities
6. Write in third person (e.g., "John is..." not "I think John is...")
7. If there are conflicting facts, note the most recent or most supported one
EXAMPLES of good observations:
- "John works at Google as a software engineer"
- "John is detail-oriented and methodical in his approach"
- "John collaborates frequently with Sarah on the AI project"
- "John joined the company in 2023"
EXAMPLES of bad observations (avoid these):
- "John seems like a good person" (opinion/judgment)
- "John probably likes his job" (assumption)
- "I believe John is reliable" (first-person opinion)
Generate 3-7 observations based on the available facts. If there are very few facts, generate fewer observations."""
def get_observation_system_message() -> str:
"""Get the system message for observation extraction."""
return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate."
async def extract_observations_from_facts(llm_config, entity_name: str, facts: list[MemoryFact]) -> list[str]:
"""
Extract observations from facts about an entity using LLM.
Args:
llm_config: LLM configuration to use
entity_name: Name of the entity to generate observations about
facts: List of facts mentioning the entity
Returns:
List of observation strings
"""
if not facts:
return []
facts_text = format_facts_for_observation_prompt(facts)
prompt = build_observation_prompt(entity_name, facts_text)
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_observation_system_message()},
{"role": "user", "content": prompt},
],
response_format=ObservationExtractionResponse,
scope="memory_extract_observation",
)
observations = [op.observation for op in result.observations]
return observations
except Exception as e:
logger.warning(f"Failed to extract observations for {entity_name}: {str(e)}")
return []
@@ -3,31 +3,13 @@ Think operation utilities for formulating answers based on agent and world facts
"""
import logging
import re
from datetime import datetime
from pydantic import BaseModel, Field
from ..response_models import DispositionTraits, MemoryFact
logger = logging.getLogger(__name__)
class Opinion(BaseModel):
"""An opinion formed by the bank."""
opinion: str = Field(description="The opinion or perspective with reasoning included")
confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
class OpinionExtractionResponse(BaseModel):
"""Response containing extracted opinions."""
opinions: list[Opinion] = Field(
default_factory=list, description="List of opinions formed with their supporting reasons and confidence scores"
)
def describe_trait_level(value: int) -> str:
"""Convert trait value (1-5) to descriptive text."""
levels = {1: "very low", 2: "low", 3: "moderate", 4: "high", 5: "very high"}
@@ -93,17 +75,46 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
return json.dumps(formatted, indent=2)
def format_entity_summaries_for_prompt(entities: dict) -> str:
"""Format entity summaries for inclusion in the reflect prompt.
Args:
entities: Dict mapping entity name to EntityState objects
Returns:
Formatted string with entity summaries, or empty string if no summaries
"""
if not entities:
return ""
summaries = []
for name, state in entities.items():
# Get summary from observations (summary is stored as single observation)
if state.observations:
summary_text = state.observations[0].text
summaries.append(f"## {name}\n{summary_text}")
if not summaries:
return ""
return "\n\n".join(summaries)
def build_think_prompt(
agent_facts_text: str,
world_facts_text: str,
opinion_facts_text: str,
query: str,
name: str,
disposition: DispositionTraits,
background: str,
context: str | None = None,
entity_summaries_text: str | None = None,
) -> str:
"""Build the think prompt for the LLM."""
"""Build the think prompt for the LLM.
Note: opinion_facts_text parameter removed - opinions are now stored as mental models
and included via entity_summaries_text.
"""
disposition_desc = build_disposition_description(disposition)
name_section = f"""
@@ -125,6 +136,14 @@ Your background:
ADDITIONAL CONTEXT:
{context}
"""
entity_section = ""
if entity_summaries_text:
entity_section = f"""
KEY PEOPLE, PLACES & THINGS I KNOW ABOUT:
{entity_summaries_text}
"""
return f"""Here's what I know and have experienced:
@@ -135,14 +154,11 @@ MY IDENTITY & EXPERIENCES:
WHAT I KNOW ABOUT THE WORLD:
{world_facts_text}
MY EXISTING OPINIONS & BELIEFS:
{opinion_facts_text}
{context_section}{name_section}{disposition_desc}{background_section}
{entity_section}{context_section}{name_section}{disposition_desc}{background_section}
QUESTION: {query}
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, and personal traits to give you my honest perspective."""
def get_system_message(disposition: DispositionTraits) -> str:
@@ -175,122 +191,11 @@ def get_system_message(disposition: DispositionTraits) -> str:
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]:
"""
Extract opinions with reasons and confidence from text using LLM.
Args:
llm_config: LLM configuration to use
text: Text to extract opinions from
query: The original query that prompted this response
Returns:
List of Opinion objects with text and confidence
"""
extraction_prompt = f"""Extract any NEW opinions or perspectives from the answer below and rewrite them in FIRST-PERSON as if YOU are stating the opinion directly.
ORIGINAL QUESTION:
{query}
ANSWER PROVIDED:
{text}
Your task: Find opinions in the answer and rewrite them AS IF YOU ARE THE ONE SAYING THEM.
An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts.
IMPORTANT: Do NOT extract statements like:
- "I don't have enough information"
- "The facts don't contain information about X"
- "I cannot answer because..."
ONLY extract actual opinions about substantive topics.
CRITICAL FORMAT REQUIREMENTS:
1. **ALWAYS start with first-person phrases**: "I think...", "I believe...", "In my view...", "I've come to believe...", "Previously I thought... but now..."
2. **NEVER use third-person**: Do NOT say "The speaker thinks..." or "They believe..." - always use "I"
3. Include the reasoning naturally within the statement
4. Provide a confidence score (0.0 to 1.0)
CORRECT Examples (✓ FIRST-PERSON):
- "I think Alice is more reliable because she consistently delivers on time and writes clean code"
- "Previously I thought all engineers were equal, but now I feel that experience and track record really matter"
- "I believe reliability is best measured by consistent output over time"
- "I've come to believe that track records are more important than potential"
WRONG Examples (✗ THIRD-PERSON - DO NOT USE):
- "The speaker thinks Alice is more reliable"
- "They believe reliability matters"
- "It is believed that Alice is better"
If no genuine opinions are expressed (e.g., the response just says "I don't know"), return an empty list."""
try:
result = await llm_config.call(
messages=[
{
"role": "system",
"content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.",
},
{"role": "user", "content": extraction_prompt},
],
response_format=OpinionExtractionResponse,
scope="memory_extract_opinion",
)
# Format opinions with confidence score and convert to first-person
formatted_opinions = []
for op in result.opinions:
# Convert third-person to first-person if needed
opinion_text = op.opinion
# Replace common third-person patterns with first-person
def singularize_verb(verb):
if verb.endswith("es"):
return verb[:-1] # believes -> believe
elif verb.endswith("s"):
return verb[:-1] # thinks -> think
return verb
# Pattern: "The speaker/user [verb]..." -> "I [verb]..."
match = re.match(
r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$",
opinion_text,
re.IGNORECASE,
)
if match:
verb = singularize_verb(match.group(2))
that_part = match.group(3) or "" # Keep " that" if present
rest = match.group(4)
opinion_text = f"I {verb}{that_part}{rest}"
# If still doesn't start with first-person, prepend "I believe that "
first_person_starters = [
"I think",
"I believe",
"I feel",
"In my view",
"I've come to believe",
"Previously I",
]
if not any(opinion_text.startswith(starter) for starter in first_person_starters):
opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence))
return formatted_opinions
except Exception as e:
logger.warning(f"Failed to extract opinions: {str(e)}")
return []
async def reflect(
llm_config,
query: str,
experience_facts: list[str] = None,
world_facts: list[str] = None,
opinion_facts: list[str] = None,
name: str = "Assistant",
disposition: DispositionTraits = None,
background: str = "",
@@ -307,7 +212,6 @@ async def reflect(
query: Question to answer
experience_facts: List of experience/agent fact strings
world_facts: List of world fact strings
opinion_facts: List of opinion fact strings
name: Name of the agent/persona
disposition: Disposition traits (defaults to neutral)
background: Background information
@@ -328,18 +232,15 @@ async def reflect(
agent_results = to_memory_facts(experience_facts or [], "experience")
world_results = to_memory_facts(world_facts or [], "world")
opinion_results = to_memory_facts(opinion_facts or [], "opinion")
# Format facts for prompt
agent_facts_text = format_facts_for_prompt(agent_results)
world_facts_text = format_facts_for_prompt(world_results)
opinion_facts_text = format_facts_for_prompt(opinion_results)
# Build prompt
prompt = build_think_prompt(
agent_facts_text=agent_facts_text,
world_facts_text=world_facts_text,
opinion_facts_text=opinion_facts_text,
query=query,
name=name,
disposition=disposition,
+2
View File
@@ -221,6 +221,8 @@ def main():
task_backend=config.task_backend,
task_backend_memory_batch_size=config.task_backend_memory_batch_size,
task_backend_memory_batch_interval=config.task_backend_memory_batch_interval,
reflect_max_iterations=config.reflect_max_iterations,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
)
config.configure_logging()
if not args.daemon:
+1 -110
View File
@@ -1,5 +1,5 @@
"""
Tests for agent management API (profile, disposition, background).
Tests for agent management API (profile, disposition).
"""
import pytest
import uuid
@@ -25,15 +25,12 @@ class TestAgentProfile:
assert profile is not None
assert "disposition" in profile
assert "background" in profile
disposition = profile["disposition"]
assert disposition.skepticism == 3
assert disposition.literalism == 3
assert disposition.empathy == 3
assert profile["background"] == ""
@pytest.mark.asyncio
async def test_update_agent_disposition(self, memory: MemoryEngine, request_context):
"""Test updating agent disposition traits."""
@@ -76,63 +73,10 @@ class TestAgentProfile:
for agent in agents:
assert "bank_id" in agent
assert "disposition" in agent
assert "background" in agent
assert "created_at" in agent
assert "updated_at" in agent
class TestAgentBackground:
"""Tests for agent background management."""
@pytest.mark.asyncio
async def test_merge_agent_background(self, memory: MemoryEngine, request_context):
"""Test merging agent background information."""
bank_id = unique_agent_id("test_profile_merge")
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert profile["background"] == ""
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Texas",
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"I have 10 years of startup experience",
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result2["background"] or "startup" in result2["background"]
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["background"] != ""
@pytest.mark.asyncio
async def test_merge_background_handles_conflicts(self, memory: MemoryEngine, request_context):
"""Test that merging background handles conflicts (new overwrites old)."""
bank_id = unique_agent_id("test_profile_conflict")
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Colorado",
update_disposition=False,
request_context=request_context,
)
assert "Colorado" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"You were born in Texas",
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result2["background"]
class TestAgentEndpoint:
"""Tests for agent PUT endpoint logic."""
@@ -147,7 +91,6 @@ class TestAgentEndpoint:
literalism=5,
empathy=2
),
background="I am a creative software engineer"
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -159,55 +102,10 @@ class TestAgentEndpoint:
request_context=request_context,
)
if request.background is not None:
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
request.background
)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["disposition"].skepticism == 4
assert final_profile["disposition"].literalism == 5
assert final_profile["background"] == "I am a creative software engineer"
@pytest.mark.asyncio
async def test_put_agent_partial_update(self, memory: MemoryEngine, request_context):
"""Test updating only background."""
bank_id = unique_agent_id("test_put_partial")
request = CreateBankRequest(
background="I am a data scientist"
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
if request.background is not None:
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
request.background
)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["disposition"].skepticism == 3 # Default
assert final_profile["background"] == "I am a data scientist"
class TestAgentDispositionIntegration:
@@ -225,13 +123,6 @@ class TestAgentDispositionIntegration:
}
await memory.update_bank_disposition(bank_id, disposition, request_context=request_context)
await memory.merge_bank_background(
bank_id,
"I am a creative artist who values innovation over tradition",
update_disposition=False,
request_context=request_context,
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
@@ -0,0 +1,516 @@
"""Tests for emergent entity filtering."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from hindsight_api.engine.mental_models.emergent import (
build_mission_filter_prompt,
evaluate_emergent_models,
filter_candidates_by_mission,
MissionFilterResponse,
MissionFilterCandidate,
)
from hindsight_api.engine.mental_models.models import EmergentCandidate
class TestBuildMissionFilterPrompt:
"""Test prompt building for mission filtering."""
def test_prompt_contains_mission(self):
"""Test that prompt includes the mission."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
)
]
prompt = build_mission_filter_prompt("Be a PM for engineering team", candidates)
assert "Be a PM for engineering team" in prompt
def test_prompt_contains_candidates(self):
"""Test that prompt includes all candidates."""
candidates = [
EmergentCandidate(
name="Alice Chen",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="Project Phoenix",
detection_method="named_entity_extraction",
mention_count=5,
),
]
prompt = build_mission_filter_prompt("Track projects", candidates)
assert "Alice Chen" in prompt
assert "Project Phoenix" in prompt
def test_prompt_contains_rejection_guidance(self):
"""Test that prompt contains guidance to reject generic entities."""
candidates = [
EmergentCandidate(
name="test",
detection_method="named_entity_extraction",
mention_count=1,
)
]
prompt = build_mission_filter_prompt("Test mission", candidates)
# Should contain rejection guidance for generic terms
assert "promote=false" in prompt
assert "kids" in prompt # Example of generic term to reject
assert "community" in prompt # Example of abstract concept to reject
assert "motivation" in prompt # Example of abstract concept to reject
class TestFilterCandidatesByMission:
"""Test the filter_candidates_by_mission function."""
@pytest.fixture
def mock_llm_config(self):
"""Create a mock LLM config."""
config = MagicMock()
config.call = AsyncMock()
return config
async def test_empty_candidates(self, mock_llm_config):
"""Test with empty candidate list."""
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Test mission",
candidates=[],
)
assert result == []
mock_llm_config.call.assert_not_called()
async def test_no_mission_keeps_all(self, mock_llm_config):
"""Test that no mission keeps all candidates (skips filtering)."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
)
]
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="", # Empty mission
candidates=candidates,
)
assert len(result) == 1
assert result[0].name == "Alice"
mock_llm_config.call.assert_not_called()
async def test_filters_by_promote_flag(self, mock_llm_config):
"""Test that candidates are filtered by promote flag."""
candidates = [
EmergentCandidate(
name="Alice Chen",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="community",
detection_method="named_entity_extraction",
mention_count=5,
),
]
# Mock LLM response - Alice is promoted, community is not
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="Alice Chen", promote=True, reason="Specific person"),
MissionFilterCandidate(name="community", promote=False, reason="Generic abstract concept"),
]
)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Be a PM for engineering team",
candidates=candidates,
)
assert len(result) == 1
assert result[0].name == "Alice Chen"
async def test_rejects_generic_entities(self, mock_llm_config):
"""Test that generic entities are rejected."""
# These are all generic/abstract terms that should be rejected
generic_names = [
"user", "support", "community", "family", "motivation",
"photo", "gratitude", "difference", "volunteering",
"kids", "veterans", "impact", "kindness", "encouragement",
"education", "nature", "joy", "positivity", "inspiration",
"help", "commitment", "passion", "energy", "connection",
]
candidates = [
EmergentCandidate(
name=name,
detection_method="named_entity_extraction",
mention_count=10,
)
for name in generic_names
]
# Add some valid candidates
valid_candidates = [
EmergentCandidate(
name="John",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="Maria",
detection_method="named_entity_extraction",
mention_count=8,
),
EmergentCandidate(
name="Max",
detection_method="named_entity_extraction",
mention_count=6,
),
]
candidates.extend(valid_candidates)
# Mock LLM response - reject all generic, promote only specific names
response_candidates = [
MissionFilterCandidate(name=name, promote=False, reason="Generic/abstract term")
for name in generic_names
]
response_candidates.extend([
MissionFilterCandidate(name=c.name, promote=True, reason="Specific person name")
for c in valid_candidates
])
mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Be a health coach",
candidates=candidates,
)
# Should only have John, Maria, and Max
result_names = {c.name for c in result}
assert result_names == {"John", "Maria", "Max"}
async def test_accepts_specific_named_entities(self, mock_llm_config):
"""Test that specific named entities are accepted."""
# These should all be accepted
valid_names = [
"Alice Chen", # Full name
"Dr. Smith", # Title + name
"John", # First name (when it's clearly a person)
"Google", # Organization
"Frontend Team", # Named team
"Project Phoenix", # Named project
"NYC Office", # Named place
"Q4 Planning", # Named event
"Sprint 23 Review", # Named meeting
]
candidates = [
EmergentCandidate(
name=name,
detection_method="named_entity_extraction",
mention_count=10,
)
for name in valid_names
]
# Mock LLM response - promote all
response_candidates = [
MissionFilterCandidate(name=name, promote=True, reason="Specific named entity")
for name in valid_names
]
mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Be a PM for engineering team",
candidates=candidates,
)
# Should have all valid names
result_names = {c.name for c in result}
assert result_names == set(valid_names)
async def test_llm_error_rejects_all_candidates(self, mock_llm_config):
"""Test that LLM errors result in rejecting all candidates (fail-safe)."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
)
]
mock_llm_config.call.side_effect = Exception("LLM error")
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Test mission",
candidates=candidates,
)
# Should reject all candidates on error (fail-safe)
assert len(result) == 0
async def test_missing_candidate_in_response_is_rejected(self, mock_llm_config):
"""Test that candidates not in LLM response are rejected by default."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="Bob",
detection_method="named_entity_extraction",
mention_count=5,
),
]
# Mock LLM response - only includes Alice, not Bob
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="Alice", promote=True, reason="Specific person"),
]
)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Test mission",
candidates=candidates,
)
# Only Alice should be in result (Bob was missing from response, so rejected)
assert len(result) == 1
assert result[0].name == "Alice"
class TestEvaluateEmergentModels:
"""Test the evaluate_emergent_models function for cleanup of existing models."""
@pytest.fixture
def mock_llm_config(self):
"""Create a mock LLM config."""
config = MagicMock()
config.call = AsyncMock()
return config
async def test_empty_models(self, mock_llm_config):
"""Test with empty model list."""
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=[],
)
assert result == []
mock_llm_config.call.assert_not_called()
async def test_removes_generic_models(self, mock_llm_config):
"""Test that generic/abstract models are marked for removal."""
models = [
{"id": "id-kids", "name": "kids"},
{"id": "id-community", "name": "community"},
{"id": "id-motivation", "name": "motivation"},
{"id": "id-john", "name": "John"},
{"id": "id-maria", "name": "Maria"},
]
# Mock LLM response - reject generic, keep specific names
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="kids", promote=False, reason="Generic category"),
MissionFilterCandidate(name="community", promote=False, reason="Abstract concept"),
MissionFilterCandidate(name="motivation", promote=False, reason="Abstract concept"),
MissionFilterCandidate(name="John", promote=True, reason="Person name"),
MissionFilterCandidate(name="Maria", promote=True, reason="Person name"),
]
)
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# Should return IDs of generic models to remove
assert set(result) == {"id-kids", "id-community", "id-motivation"}
async def test_keeps_specific_named_models(self, mock_llm_config):
"""Test that specific named models are kept."""
models = [
{"id": "id-john", "name": "John"},
{"id": "id-google", "name": "Google"},
{"id": "id-project", "name": "Project Phoenix"},
]
# Mock LLM response - keep all
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="John", promote=True, reason="Person name"),
MissionFilterCandidate(name="Google", promote=True, reason="Organization"),
MissionFilterCandidate(name="Project Phoenix", promote=True, reason="Named project"),
]
)
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# No models should be removed
assert result == []
async def test_llm_error_keeps_all_models(self, mock_llm_config):
"""Test that LLM errors result in keeping all models (safe default)."""
models = [
{"id": "id-kids", "name": "kids"},
{"id": "id-john", "name": "John"},
]
mock_llm_config.call.side_effect = Exception("LLM error")
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# Should keep all models on error (return empty removal list)
assert result == []
async def test_missing_model_in_response_is_removed(self, mock_llm_config):
"""Test that models not in LLM response are marked for removal."""
models = [
{"id": "id-alice", "name": "Alice"},
{"id": "id-bob", "name": "Bob"},
]
# Mock LLM response - only includes Alice
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="Alice", promote=True, reason="Person name"),
]
)
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# Bob should be marked for removal (missing from response)
assert result == ["id-bob"]
class TestRemovedEntitiesNotRepromoted:
"""Test that entities removed by evaluation are not re-promoted.
This tests the fix for a bug where:
1. evaluate_emergent_models returns model IDs to remove (e.g., 'entity-maya')
2. We delete those models
3. detect_entity_candidates finds the same entities (now eligible since model was deleted)
4. filter_candidates_by_goal approves them (different LLM call)
5. BUG: We were re-promoting the same entities we just removed
The fix tracks removed entity_ids and excludes them from promotion.
"""
async def test_removed_entity_ids_excluded_from_promotion(self):
"""Test that entities whose models were removed are not re-promoted."""
from hindsight_api.engine.mental_models.models import EmergentCandidate
# Simulate the scenario from the bug:
# - existing_emergent has model 'entity-maya' with entity_id='uuid-maya'
# - evaluate_emergent_models says to remove 'entity-maya'
# - detect_entity_candidates returns 'Maya' with entity_id='uuid-maya' (now eligible)
# - filter_candidates_by_goal says to promote 'Maya'
# - But we should NOT promote because we just removed it
existing_emergent = [
{"id": "entity-maya", "name": "Maya", "entity_id": "uuid-maya"},
{"id": "entity-alex", "name": "Alex", "entity_id": "uuid-alex"},
{"id": "entity-john", "name": "John", "entity_id": "uuid-john"}, # This one will be kept
]
# Models to remove (evaluate_emergent_models would return these)
models_to_remove = ["entity-maya", "entity-alex"]
# Build model_id -> entity_id mapping (this is what the fix does)
model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent}
# Track removed entity_ids
removed_entity_ids: set[str] = set()
for model_id in models_to_remove:
entity_id = model_to_entity.get(model_id)
if entity_id:
removed_entity_ids.add(str(entity_id))
# Verify we tracked the right entity_ids
assert removed_entity_ids == {"uuid-maya", "uuid-alex"}
# Now simulate candidates that were detected (includes removed entities)
candidates = [
EmergentCandidate(
name="Maya", entity_id="uuid-maya", detection_method="named_entity", mention_count=10
),
EmergentCandidate(
name="Alex", entity_id="uuid-alex", detection_method="named_entity", mention_count=8
),
EmergentCandidate(
name="NewPerson", entity_id="uuid-new", detection_method="named_entity", mention_count=5
),
]
# Filter out candidates whose entity was just removed (the fix)
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
# Only NewPerson should remain - Maya and Alex were removed and should not be re-promoted
assert len(filtered_candidates) == 1
assert filtered_candidates[0].name == "NewPerson"
assert filtered_candidates[0].entity_id == "uuid-new"
async def test_candidates_without_matching_removal_are_kept(self):
"""Test that candidates not in the removed set are still promoted."""
from hindsight_api.engine.mental_models.models import EmergentCandidate
# No models removed
removed_entity_ids: set[str] = set()
candidates = [
EmergentCandidate(
name="Alice", entity_id="uuid-alice", detection_method="named_entity", mention_count=10
),
EmergentCandidate(
name="Bob", entity_id="uuid-bob", detection_method="named_entity", mention_count=8
),
]
# Filter (should keep all since nothing was removed)
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
assert len(filtered_candidates) == 2
assert {c.name for c in filtered_candidates} == {"Alice", "Bob"}
async def test_partial_removal_keeps_other_candidates(self):
"""Test that only removed entities are excluded, others pass through."""
from hindsight_api.engine.mental_models.models import EmergentCandidate
# Only one entity removed
removed_entity_ids = {"uuid-removed"}
candidates = [
EmergentCandidate(
name="Removed", entity_id="uuid-removed", detection_method="named_entity", mention_count=10
),
EmergentCandidate(
name="Kept1", entity_id="uuid-kept1", detection_method="named_entity", mention_count=8
),
EmergentCandidate(
name="Kept2", entity_id="uuid-kept2", detection_method="named_entity", mention_count=5
),
]
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
assert len(filtered_candidates) == 2
assert {c.name for c in filtered_candidates} == {"Kept1", "Kept2"}
@@ -947,172 +947,3 @@ so the algorithm learns to box out. See you next week!
raise e
# =============================================================================
# DISPOSITION INFERENCE TESTS
# =============================================================================
class TestDispositionInference:
"""Tests for LLM-based disposition trait inference from background."""
@pytest.mark.asyncio
async def test_background_merge_with_disposition_inference(self, memory, request_context):
"""Test that background merge infers disposition traits by default."""
import uuid
bank_id = f"test_infer_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a creative software engineer who loves innovation and trying new technologies",
update_disposition=True,
request_context=request_context,
)
assert "background" in result
assert "disposition" in result
background = result["background"]
disposition = result["disposition"]
assert "creative" in background.lower() or "innovation" in background.lower()
# Check that new traits are present with valid values (1-5)
required_traits = ["skepticism", "literalism", "empathy"]
for trait in required_traits:
assert trait in disposition
assert 1 <= disposition[trait] <= 5
@pytest.mark.asyncio
async def test_background_merge_without_disposition_inference(self, memory, request_context):
"""Test that background merge skips disposition inference when disabled."""
import uuid
bank_id = f"test_no_infer_{uuid.uuid4().hex[:8]}"
initial_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
initial_disposition = initial_profile["disposition"]
result = await memory.merge_bank_background(
bank_id,
"I am a data scientist",
update_disposition=False,
request_context=request_context,
)
assert "background" in result
assert "disposition" not in result
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
final_disposition = final_profile["disposition"]
assert initial_disposition == final_disposition
@pytest.mark.asyncio
async def test_disposition_inference_for_lawyer(self, memory, request_context):
"""Test disposition inference for lawyer profile (high skepticism, high literalism)."""
import uuid
bank_id = f"test_lawyer_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a lawyer who focuses on contract details and never takes claims at face value",
update_disposition=True,
request_context=request_context,
)
disposition = result["disposition"]
# Lawyers should have higher skepticism and literalism
assert disposition["skepticism"] >= 3
assert disposition["literalism"] >= 3
@pytest.mark.asyncio
async def test_disposition_inference_for_therapist(self, memory, request_context):
"""Test disposition inference for therapist profile (high empathy)."""
import uuid
bank_id = f"test_therapist_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a therapist who deeply understands and connects with people's emotional struggles",
update_disposition=True,
request_context=request_context,
)
disposition = result["disposition"]
# Therapists should have higher empathy
assert disposition["empathy"] >= 3
@pytest.mark.asyncio
async def test_disposition_updates_in_database(self, memory, request_context):
"""Test that inferred disposition is actually stored in database."""
import uuid
bank_id = f"test_db_update_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am an innovative designer",
update_disposition=True,
request_context=request_context,
)
inferred_disposition = result["disposition"]
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
db_disposition = profile["disposition"]
# Compare values (db_disposition is a Pydantic model)
assert db_disposition.skepticism == inferred_disposition["skepticism"]
assert db_disposition.literalism == inferred_disposition["literalism"]
assert db_disposition.empathy == inferred_disposition["empathy"]
@pytest.mark.asyncio
async def test_multiple_background_merges_update_disposition(self, memory, request_context):
"""Test that each background merge can update disposition."""
import uuid
bank_id = f"test_multi_merge_{uuid.uuid4().hex[:8]}"
result1 = await memory.merge_bank_background(
bank_id,
"I am a software engineer",
update_disposition=True,
request_context=request_context,
)
disposition1 = result1["disposition"]
result2 = await memory.merge_bank_background(
bank_id,
"I love creative problem solving and innovation",
update_disposition=True,
request_context=request_context,
)
disposition2 = result2["disposition"]
assert "engineer" in result2["background"].lower() or "software" in result2["background"].lower()
assert "creative" in result2["background"].lower() or "innovation" in result2["background"].lower()
@pytest.mark.asyncio
async def test_background_merge_conflict_resolution_with_disposition(self, memory, request_context):
"""Test that conflicts are resolved and disposition reflects final background."""
import uuid
bank_id = f"test_conflict_{uuid.uuid4().hex[:8]}"
await memory.merge_bank_background(
bank_id,
"I was born in Colorado and prefer stability",
update_disposition=True,
request_context=request_context,
)
result = await memory.merge_bank_background(
bank_id,
"You were born in Texas and are very skeptical of people",
update_disposition=True,
request_context=request_context,
)
background = result["background"]
disposition = result["disposition"]
assert "texas" in background.lower()
# Higher skepticism expected from "very skeptical of people"
assert disposition["skepticism"] >= 3
+27 -28
View File
@@ -51,7 +51,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
results = await memory.recall_async(
bank_id=bank_id,
query="Marcus prediction Rams",
fact_type=['opinion', 'experience', 'world'],
fact_type=['experience', 'world'],
budget=Budget.LOW,
max_tokens=8192,
request_context=request_context,
@@ -61,8 +61,8 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
for i, result in enumerate(results.results):
print(f"{i+1}. [{result.mentioned_at}] {result.text[:100]}")
# Get all opinion facts (Marcus's predictions/statements)
agent_facts = [r for r in results.results if r.fact_type == 'opinion']
# Get all facts (Marcus's predictions/statements)
agent_facts = results.results
print(f"\n=== Agent facts (Marcus's statements) ===")
for i, fact in enumerate(agent_facts):
@@ -70,6 +70,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
# Check that agent facts have different timestamps
if len(agent_facts) >= 2:
# Parse timestamps
timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in agent_facts]
# Verify timestamps are different (have time offsets)
@@ -77,42 +78,40 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
assert len(unique_timestamps) == len(timestamps), \
f"Expected unique timestamps for each fact, but got duplicates: {timestamps}"
# Verify timestamps are in order (ascending)
for i in range(len(timestamps) - 1):
assert timestamps[i] < timestamps[i + 1], \
f"Facts should be ordered by time. Fact {i} ({timestamps[i]}) >= Fact {i+1} ({timestamps[i+1]})"
# Sort facts by timestamp for ordering check
# Note: recall returns by relevance, not time order
sorted_facts = sorted(agent_facts, key=lambda f: datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')))
sorted_timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in sorted_facts]
# Verify sorted timestamps are in ascending order
for i in range(len(sorted_timestamps) - 1):
assert sorted_timestamps[i] < sorted_timestamps[i + 1], \
f"Facts should have sequential timestamps. Fact {i} ({sorted_timestamps[i]}) >= Fact {i+1} ({sorted_timestamps[i+1]})"
# Verify reasonable time spacing (should be ~10 seconds apart)
time_diffs = [(timestamps[i+1] - timestamps[i]).total_seconds() for i in range(len(timestamps) - 1)]
time_diffs = [(sorted_timestamps[i+1] - sorted_timestamps[i]).total_seconds() for i in range(len(sorted_timestamps) - 1)]
print(f"\n=== Time differences between facts: {time_diffs} seconds ===")
# Each fact should be 10+ seconds apart (allowing for some flexibility)
for diff in time_diffs:
assert diff >= 5, f"Expected at least 5 seconds between facts, got {diff}"
# Update agent_facts to be sorted for subsequent checks
agent_facts = sorted_facts
timestamps = sorted_timestamps
print(f"\n✅ All {len(agent_facts)} agent facts have properly ordered timestamps")
# Verify that retrieval returns facts in chronological order
# The first prediction should come before the changed prediction
# Verify that facts capture the key information
# Note: LLM may merge related predictions into single facts
agent_texts = [f.text.lower() for f in agent_facts]
all_text = " ".join(agent_texts)
# Look for evidence of the sequence
has_first_prediction = any('27' in text and '24' in text for text in agent_texts)
has_changed_prediction = any('chang' in text or 'by 3' in text or 'realized' in text for text in agent_texts)
# Look for evidence of the predictions being captured (may be merged or separate)
has_prediction_info = '27' in all_text or 'rams' in all_text or 'prediction' in all_text
if has_first_prediction and has_changed_prediction:
# Find indices
first_idx = next(i for i, text in enumerate(agent_texts) if '27' in text and '24' in text)
changed_idx = next(i for i, text in enumerate(agent_texts) if 'chang' in text or 'by 3' in text or 'realized' in text)
print(f"\nFirst prediction at index {first_idx}: {agent_facts[first_idx].text[:100]}")
print(f"Changed prediction at index {changed_idx}: {agent_facts[changed_idx].text[:100]}")
# The original prediction should come before the changed one
assert timestamps[first_idx] < timestamps[changed_idx], \
"Original prediction should have earlier timestamp than changed prediction"
print(f"\n✅ Temporal ordering preserved: First prediction came before changed prediction")
assert has_prediction_info, "Facts should contain information about Marcus's predictions"
print(f"\n✅ Facts capture prediction information")
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -156,14 +155,14 @@ Alice: I reconsidered the team's experience level.
results = await memory.recall_async(
bank_id=bank_id,
query="Alice preference React Vue",
fact_type=['opinion', 'experience'],
fact_type=['experience', 'world'],
budget=Budget.LOW,
max_tokens=8192,
request_context=request_context,
)
print(f"\n=== Retrieved {len(results.results)} agent facts ===")
agent_facts = [r for r in results.results if r.fact_type in ('opinion', 'experience')]
agent_facts = results.results
for i, fact in enumerate(agent_facts):
print(f"{i+1}. [{fact.mentioned_at}] {fact.text[:80]}")
@@ -60,17 +60,6 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert response.status_code == 200
profile = response.json()
assert "disposition" in profile
assert "background" in profile
# Add background
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/background",
json={
"content": "A software engineer passionate about AI and memory systems."
}
)
assert response.status_code == 200
assert "software engineer" in response.json()["background"].lower()
# ================================================================
# 2. Memory Storage
@@ -244,7 +233,9 @@ async def test_full_api_workflow(api_client, test_bank_id):
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
updated_profile = response.json()
assert "software engineer" in updated_profile["background"].lower()
assert updated_profile["disposition"]["skepticism"] == 4
assert updated_profile["disposition"]["literalism"] == 3
assert updated_profile["disposition"]["empathy"] == 4
# ================================================================
# 8. Test Entity Endpoints
@@ -289,11 +280,11 @@ async def test_full_api_workflow(api_client, test_bank_id):
entity_detail = response.json()
assert "id" in entity_detail
# Test regenerate observations
# Test regenerate observations (deprecated - returns 410 Gone)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/entities/{entity_id}/regenerate"
)
assert response.status_code == 200
assert response.status_code == 410 # Deprecated endpoint
# ================================================================
# 9. List All Banks (should include our test bank)
@@ -845,9 +836,8 @@ async def test_reflect_structured_output(api_client):
assert response.status_code == 200
result = response.json()
# Verify text field exists (empty when using structured output)
# Verify text field exists (may contain text even with structured output)
assert "text" in result
assert result["text"] == ""
# Verify structured output exists and has expected structure
assert "structured_output" in result
@@ -979,20 +969,24 @@ async def test_reflect_returns_token_usage(api_client):
assert "text" in result
assert len(result["text"]) > 0
# Verify usage field exists and has expected structure
# Verify usage field exists (may be None for agentic reflect which makes multiple LLM calls)
assert "usage" in result, "Response should include 'usage' field"
usage = result["usage"]
assert usage is not None, "Usage should not be None for reflect"
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
# Verify token counts are valid
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
# Usage is optional - agentic reflect doesn't aggregate multiple LLM call usages
if usage is not None:
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
# Verify token counts are valid
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
else:
print("Reflect usage is None (expected for agentic reflect)")
@pytest.mark.asyncio
+337
View File
@@ -0,0 +1,337 @@
"""
Tests for LLM tool calling functionality.
"""
import pytest
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult
# Sample tools for testing
SAMPLE_TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
},
{
"type": "function",
"function": {
"name": "search",
"description": "Search for information",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
},
"required": ["query"],
},
},
},
]
class TestMockToolCalling:
"""Test tool calling with mock provider."""
@pytest.mark.asyncio
async def test_call_with_tools_returns_tool_calls(self):
"""Test that mock provider can return tool calls."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
# Set mock response to return tool calls
llm.set_mock_response([
{"name": "get_weather", "arguments": {"location": "Paris", "unit": "celsius"}},
])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
tools=SAMPLE_TOOLS,
)
assert isinstance(result, LLMToolCallResult)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[0].arguments == {"location": "Paris", "unit": "celsius"}
assert result.finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_call_with_tools_returns_content(self):
"""Test that mock provider can return plain content."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
# Default mock response is plain content
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Hello"}],
tools=SAMPLE_TOOLS,
)
assert isinstance(result, LLMToolCallResult)
assert result.content == "mock response"
assert len(result.tool_calls) == 0
assert result.finish_reason == "stop"
@pytest.mark.asyncio
async def test_call_with_tools_records_calls(self):
"""Test that mock calls are recorded."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
llm.clear_mock_calls()
await llm.call_with_tools(
messages=[{"role": "user", "content": "Test message"}],
tools=SAMPLE_TOOLS,
scope="test_scope",
)
calls = llm.get_mock_calls()
assert len(calls) == 1
assert calls[0]["scope"] == "test_scope"
assert "get_weather" in calls[0]["tools"]
assert "search" in calls[0]["tools"]
@pytest.mark.asyncio
async def test_call_with_tools_multiple_tool_calls(self):
"""Test handling multiple tool calls in one response."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
llm.set_mock_response([
{"name": "get_weather", "arguments": {"location": "Paris"}},
{"name": "search", "arguments": {"query": "weather forecast"}},
])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Weather in Paris and search for forecasts"}],
tools=SAMPLE_TOOLS,
)
assert len(result.tool_calls) == 2
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[1].name == "search"
@pytest.mark.asyncio
async def test_call_with_tools_accepts_llm_tool_call_result(self):
"""Test that mock can accept LLMToolCallResult directly."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
expected_result = LLMToolCallResult(
content="Here's the info",
tool_calls=[LLMToolCall(id="call_123", name="search", arguments={"query": "test"})],
finish_reason="tool_calls",
)
llm.set_mock_response(expected_result)
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Search for test"}],
tools=SAMPLE_TOOLS,
)
assert result == expected_result
class TestToolCallConversation:
"""Test tool call conversation flow."""
@pytest.mark.asyncio
async def test_tool_result_message_format(self):
"""Test that tool result messages can be passed in subsequent calls."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
# First call returns tool call
llm.set_mock_response([{"name": "get_weather", "arguments": {"location": "Paris"}}])
result1 = await llm.call_with_tools(
messages=[{"role": "user", "content": "What's the weather?"}],
tools=SAMPLE_TOOLS,
)
# Build conversation with tool result
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"tool_calls": [
{
"id": result1.tool_calls[0].id,
"type": "function",
"function": {
"name": result1.tool_calls[0].name,
"arguments": '{"location": "Paris"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": result1.tool_calls[0].id,
"content": '{"temperature": 20, "conditions": "sunny"}',
},
]
# Second call should work with tool result in history
llm.set_mock_response(None) # Reset to default
result2 = await llm.call_with_tools(
messages=messages,
tools=SAMPLE_TOOLS,
)
assert result2.content == "mock response"
class TestToolSchemas:
"""Test tool schema handling."""
@pytest.mark.asyncio
async def test_empty_tools_list(self):
"""Test calling with empty tools list."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Hello"}],
tools=[],
)
assert result.content == "mock response"
@pytest.mark.asyncio
async def test_tool_with_no_required_params(self):
"""Test tool with no required parameters."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
tools = [
{
"type": "function",
"function": {
"name": "list_items",
"description": "List all items",
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
]
llm.set_mock_response([{"name": "list_items", "arguments": {}}])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "List items"}],
tools=tools,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "list_items"
assert result.tool_calls[0].arguments == {}
class TestReflectToolSchemas:
"""Test reflect agent tool schemas."""
def test_get_reflect_tools_default(self):
"""Test getting default reflect tools."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools()
tool_names = [t["function"]["name"] for t in tools]
assert "list_mental_models" in tool_names
assert "get_mental_model" in tool_names
assert "recall" in tool_names
assert "learn" in tool_names
assert "expand" in tool_names
assert "done" in tool_names
def test_get_reflect_tools_without_learn(self):
"""Test getting reflect tools without learn."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools(enable_learn=False)
tool_names = [t["function"]["name"] for t in tools]
assert "learn" not in tool_names
assert "recall" in tool_names
assert "done" in tool_names
def test_get_reflect_tools_observations_mode(self):
"""Test getting reflect tools with observations output mode."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools(output_mode="observations")
done_tool = next(t for t in tools if t["function"]["name"] == "done")
params = done_tool["function"]["parameters"]["properties"]
assert "observations" in params
assert "answer" not in params
def test_get_reflect_tools_answer_mode(self):
"""Test getting reflect tools with answer output mode."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools(output_mode="answer")
done_tool = next(t for t in tools if t["function"]["name"] == "done")
params = done_tool["function"]["parameters"]["properties"]
assert "answer" in params
assert "memory_ids" in params
assert "model_ids" in params
class TestLLMToolCallResult:
"""Test LLMToolCallResult model."""
def test_tool_call_result_defaults(self):
"""Test default values for LLMToolCallResult."""
result = LLMToolCallResult()
assert result.content is None
assert result.tool_calls == []
assert result.finish_reason is None
def test_tool_call_result_with_content(self):
"""Test LLMToolCallResult with content."""
result = LLMToolCallResult(content="Hello", finish_reason="stop")
assert result.content == "Hello"
assert result.tool_calls == []
assert result.finish_reason == "stop"
def test_tool_call_result_with_tool_calls(self):
"""Test LLMToolCallResult with tool calls."""
result = LLMToolCallResult(
tool_calls=[
LLMToolCall(id="call_1", name="test_tool", arguments={"arg": "value"}),
],
finish_reason="tool_calls",
)
assert result.content is None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "test_tool"
assert result.finish_reason == "tool_calls"
class TestLLMToolCall:
"""Test LLMToolCall model."""
def test_tool_call_basic(self):
"""Test basic LLMToolCall creation."""
call = LLMToolCall(id="call_123", name="get_weather", arguments={"location": "Paris"})
assert call.id == "call_123"
assert call.name == "get_weather"
assert call.arguments == {"location": "Paris"}
def test_tool_call_empty_arguments(self):
"""Test LLMToolCall with empty arguments."""
call = LLMToolCall(id="call_456", name="list_items", arguments={})
assert call.arguments == {}
+795
View File
@@ -0,0 +1,795 @@
"""Tests for mental model functionality (v4 system)."""
import uuid
import pytest
from hindsight_api.engine.memory_engine import MemoryEngine
@pytest.fixture
async def memory_with_mission(memory: MemoryEngine, request_context):
"""Memory engine with a bank that has a mission set.
Uses a unique bank_id to avoid conflicts between parallel tests.
"""
# Use unique bank_id to avoid conflicts between parallel tests
bank_id = f"test-mental-models-{uuid.uuid4().hex[:8]}"
# Set up the bank with a mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Be a PM for the engineering team",
request_context=request_context,
)
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "The team has daily standups at 9am where everyone shares their progress."},
{"content": "Alice is the frontend engineer and specializes in React."},
{"content": "Bob is the backend engineer and owns the API services."},
{"content": "Sprint retrospectives happen every two weeks to discuss improvements."},
{"content": "John is the tech lead and makes final decisions on architecture."},
],
request_context=request_context,
)
# Wait for any background tasks from retain to complete
await memory.wait_for_background_tasks()
yield memory, bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestBankMission:
"""Test bank mission operations."""
async def test_set_and_get_mission(self, memory: MemoryEngine, request_context):
"""Test setting and getting a bank's mission."""
bank_id = f"test-mission-{uuid.uuid4().hex[:8]}"
# Set mission
result = await memory.set_bank_mission(
bank_id=bank_id,
mission="Track customer feedback",
request_context=request_context,
)
assert result["bank_id"] == bank_id
assert result["mission"] == "Track customer feedback"
# Get mission via profile
profile = await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
assert profile["mission"] == "Track customer feedback"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestRefreshMentalModels:
"""Test the main refresh_mental_models flow."""
async def test_refresh_creates_structural_models(self, memory_with_mission, request_context):
"""Test that refresh creates structural models from the mission."""
memory, bank_id = memory_with_mission
# Refresh mental models (async - returns operation_id)
result = await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Check that we got an operation ID back
assert "operation_id" in result
assert result["status"] == "queued"
# Wait for background task to complete
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# Check that structural models were created
structural_models = [m for m in models if m["subtype"] == "structural"]
assert len(structural_models) > 0
# Check that models have the expected structure
for model in models:
assert "id" in model
assert "name" in model
assert "description" in model
assert model["subtype"] in ["structural", "emergent"]
async def test_refresh_without_mission_fails(self, memory: MemoryEngine, request_context):
"""Test that refresh fails when no mission is set."""
bank_id = f"test-no-mission-refresh-{uuid.uuid4().hex[:8]}"
# Add some data but don't set a mission
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the frontend engineer."},
{"content": "Bob is the backend engineer."},
],
request_context=request_context,
)
# Wait for any background tasks from retain to complete
await memory.wait_for_background_tasks()
# Refresh mental models should fail without a mission
with pytest.raises(ValueError) as exc_info:
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert "no mission is set" in str(exc_info.value).lower()
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelCRUD:
"""Test basic CRUD operations for mental models."""
async def test_list_mental_models(self, memory_with_mission, request_context):
"""Test listing mental models."""
memory, bank_id = memory_with_mission
# Refresh to create models (async)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# List all models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# Test filtering by subtype
structural_models = await memory.list_mental_models(
bank_id=bank_id,
subtype="structural",
request_context=request_context,
)
assert all(m["subtype"] == "structural" for m in structural_models)
async def test_get_mental_model(self, memory_with_mission, request_context):
"""Test getting a mental model by ID."""
memory, bank_id = memory_with_mission
# Refresh to create models (async)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Get one by ID
model_id = models[0]["id"]
model = await memory.get_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert model is not None
assert model["id"] == model_id
# Test non-existent
not_found = await memory.get_mental_model(
bank_id=bank_id,
model_id="non-existent",
request_context=request_context,
)
assert not_found is None
async def test_delete_mental_model(self, memory_with_mission, request_context):
"""Test deleting a mental model."""
memory, bank_id = memory_with_mission
# Refresh to create models (async)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Delete one
model_id = models[0]["id"]
deleted = await memory.delete_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert deleted is True
# Verify it's gone
model = await memory.get_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert model is None
# Delete non-existent returns False
deleted_again = await memory.delete_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert deleted_again is False
async def test_create_pinned_mental_model(self, memory: MemoryEngine, request_context):
"""Test creating a pinned mental model."""
bank_id = f"test-pinned-{uuid.uuid4().hex[:8]}"
# Ensure bank exists by getting its profile (auto-creates if needed)
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a pinned mental model
model = await memory.create_mental_model(
bank_id=bank_id,
name="Product Roadmap",
description="Key product priorities and upcoming features",
tags=["project-x"],
request_context=request_context,
)
assert model["name"] == "Product Roadmap"
assert model["description"] == "Key product priorities and upcoming features"
assert model["subtype"] == "pinned"
assert model["tags"] == ["project-x"]
assert model["id"] == "pinned-product-roadmap"
# Verify it can be retrieved
retrieved = await memory.get_mental_model(
bank_id=bank_id,
model_id=model["id"],
request_context=request_context,
)
assert retrieved is not None
assert retrieved["subtype"] == "pinned"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_create_pinned_model_duplicate_fails(self, memory: MemoryEngine, request_context):
"""Test that creating a duplicate pinned model fails."""
bank_id = f"test-pinned-dup-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create first model
await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
description="First model",
request_context=request_context,
)
# Try to create duplicate
with pytest.raises(ValueError) as exc_info:
await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
description="Second model",
request_context=request_context,
)
assert "already exists" in str(exc_info.value).lower()
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_pinned_models_survive_refresh(self, memory: MemoryEngine, request_context):
"""Test that pinned models are not deleted during refresh."""
bank_id = f"test-pinned-refresh-{uuid.uuid4().hex[:8]}"
# Set a mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Track customer feedback",
request_context=request_context,
)
# Create a pinned model
pinned_model = await memory.create_mental_model(
bank_id=bank_id,
name="Key Customers",
description="Important customers to track",
request_context=request_context,
)
# Refresh mental models
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Verify pinned model still exists
retrieved = await memory.get_mental_model(
bank_id=bank_id,
model_id=pinned_model["id"],
request_context=request_context,
)
assert retrieved is not None
assert retrieved["subtype"] == "pinned"
assert retrieved["name"] == "Key Customers"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelRefresh:
"""Test mental model summary refresh functionality."""
async def test_refresh_creates_models_with_summaries(self, memory_with_mission, request_context):
"""Test that refresh_mental_models creates models and generates summaries."""
memory, bank_id = memory_with_mission
# Refresh mental models (async - creates models and generates summaries)
result = await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert "operation_id" in result
assert result["status"] == "queued"
# Wait for background task to complete (includes summary generation)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# After async refresh completes, models should have summaries generated
for model in models:
assert "id" in model
assert "name" in model
# Summaries should be generated now (unless no relevant facts found)
# We don't strictly assert on summary presence since it depends on data
async def test_refresh_nonexistent_mental_model(self, memory: MemoryEngine, request_context):
"""Test refreshing a non-existent mental model returns None."""
bank_id = f"test-refresh-noexist-{uuid.uuid4().hex[:8]}"
result = await memory.refresh_mental_model(
bank_id=bank_id,
model_id="does-not-exist",
request_context=request_context,
)
assert result is None
class TestReflect:
"""Test reflect endpoint with mental models."""
async def test_reflect_basic(self, memory_with_mission, request_context):
"""Test basic reflect query - reflect works even without mental models."""
memory, bank_id = memory_with_mission
# Run a reflect query
result = await memory.reflect_async(
bank_id=bank_id,
query="Who are the team members?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
class TestMentalModelLearnTool:
"""Test mental model learn tool - creates placeholders with background generation."""
async def test_learn_creates_placeholder(self, memory: MemoryEngine, request_context):
"""Test that learn tool creates a placeholder mental model without observations."""
bank_id = f"test-source-facts-{uuid.uuid4().hex[:8]}"
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the team lead."},
{"content": "Bob is the engineer."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Directly use the learn tool to create a mental model placeholder
from hindsight_api.engine.reflect.models import MentalModelInput
from hindsight_api.engine.reflect.tools import tool_learn
input_model = MentalModelInput(
name="Team Members",
description="Key team members and their roles",
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
result = await tool_learn(conn, bank_id, input_model)
assert result["status"] == "created"
assert result["model_id"] == "team-members"
assert result["name"] == "Team Members"
assert result["pending_generation"] is True
# Verify placeholder was stored in database with empty observations
pool = await memory._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT subtype, name, description, observations FROM mental_models WHERE id = $1 AND bank_id = $2",
result["model_id"],
bank_id,
)
assert row is not None
assert row["subtype"] == "learned"
assert row["name"] == "Team Members"
assert row["description"] == "Key team members and their roles"
# Observations should be empty - will be generated in background
observations_data = row["observations"]
# Handle both string and dict representations
if isinstance(observations_data, str):
import json
observations_data = json.loads(observations_data) if observations_data else {}
assert observations_data == {} or observations_data is None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_learn_update_description(self, memory: MemoryEngine, request_context):
"""Test that updating a mental model updates the description."""
bank_id = f"test-merge-facts-{uuid.uuid4().hex[:8]}"
# Create bank by retaining some data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Test data"}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
from hindsight_api.engine.reflect.models import MentalModelInput
from hindsight_api.engine.reflect.tools import tool_learn
# First create a placeholder
input_model = MentalModelInput(
name="Team Members",
description="Initial description",
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
result1 = await tool_learn(conn, bank_id, input_model)
assert result1["status"] == "created"
assert result1["pending_generation"] is True
# Now update with new description
input_model2 = MentalModelInput(
name="Team Members", # Same name = same ID
description="Updated description with more context",
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
result2 = await tool_learn(conn, bank_id, input_model2)
assert result2["status"] == "updated"
assert result2["model_id"] == "team-members"
# Verify description was updated in database
pool = await memory._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT description FROM mental_models WHERE id = $1 AND bank_id = $2",
result1["model_id"],
bank_id,
)
assert row is not None
assert row["description"] == "Updated description with more context"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelTags:
"""Test mental model tags functionality."""
@pytest.fixture
async def memory_with_mission_and_tags(self, memory: MemoryEngine, request_context):
"""Memory engine with a bank that has a mission set and tagged content."""
bank_id = f"test-mm-tags-{uuid.uuid4().hex[:8]}"
# Set up the bank with a mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Be a PM for the engineering team",
request_context=request_context,
)
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the frontend engineer."},
{"content": "Bob is the backend engineer."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
yield memory, bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_refresh_creates_models_with_tags(self, memory_with_mission_and_tags, request_context):
"""Test that refresh_mental_models creates models with specified tags."""
memory, bank_id = memory_with_mission_and_tags
# Refresh mental models with tags
result = await memory.refresh_mental_models(
bank_id=bank_id,
tags=["project-alpha", "sprint-1"],
request_context=request_context,
)
assert "operation_id" in result
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# All models should have the tags we specified
for model in models:
assert "tags" in model
assert "project-alpha" in model["tags"]
assert "sprint-1" in model["tags"]
async def test_list_mental_models_filters_by_tags(self, memory_with_mission_and_tags, request_context):
"""Test that list_mental_models correctly filters by tags."""
memory, bank_id = memory_with_mission_and_tags
# Create models with different tags
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["project-alpha"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get all models
all_models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(all_models) > 0
# Filter by tags - should return models with matching tags
filtered_models = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-alpha"],
request_context=request_context,
)
assert len(filtered_models) == len(all_models) # All models have this tag
# Filter by non-existent tag - should only return untagged models (none here)
# But since all models have tags, and the filter includes untagged,
# we need to test with a mix
empty_filtered = await memory.list_mental_models(
bank_id=bank_id,
tags=["non-existent-tag"],
request_context=request_context,
)
# Should return empty since no models are untagged and none match
# Actually, the logic includes untagged models, so let's verify the behavior
# All our models have tags, so only checking for non-existent tag
# should return nothing (since none match and none are untagged)
async def test_untagged_models_included_in_filter(self, memory: MemoryEngine, request_context):
"""Test that untagged mental models are always included when filtering."""
bank_id = f"test-untagged-{uuid.uuid4().hex[:8]}"
# Set up bank with mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Track projects",
request_context=request_context,
)
# Add some data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Project Alpha is important."}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# First refresh without tags (creates untagged models)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context, # No tags
)
await memory.wait_for_background_tasks()
# Get all models (should be untagged)
all_models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
if len(all_models) > 0:
# Verify models are untagged
for model in all_models:
assert model.get("tags", []) == []
# Filter by any tag - untagged models should still be included
filtered_models = await memory.list_mental_models(
bank_id=bank_id,
tags=["some-tag"],
request_context=request_context,
)
# Untagged models should be included in the results
assert len(filtered_models) == len(all_models)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_tags_match_any(self, memory: MemoryEngine, request_context):
"""Test tags_match='any' returns models with at least one matching tag."""
bank_id = f"test-tags-any-{uuid.uuid4().hex[:8]}"
# Set up bank with mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Track projects",
request_context=request_context,
)
# Add data and create models with tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice works on frontend."}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["tag-a", "tag-b"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Filter with tags_match='any' - should match if any tag matches
models = await memory.list_mental_models(
bank_id=bank_id,
tags=["tag-a", "tag-c"], # tag-a matches, tag-c doesn't
tags_match="any",
request_context=request_context,
)
# Models with tag-a should be included
for model in models:
if model.get("tags"):
# At least one of the filter tags should be in the model tags
# OR model is untagged
assert (
any(t in model["tags"] for t in ["tag-a", "tag-c"])
or model["tags"] == []
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_reflect_with_tags_filter(self, memory_with_mission_and_tags, request_context):
"""Test that reflect filters memories by tags."""
memory, bank_id = memory_with_mission_and_tags
# Create mental models with tags
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["project-x"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Reflect with matching tags
result = await memory.reflect_async(
bank_id=bank_id,
query="Who are the engineers?",
tags=["project-x"],
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Reflect with non-matching tags - should still work
result2 = await memory.reflect_async(
bank_id=bank_id,
query="Who are the engineers?",
tags=["different-project"],
request_context=request_context,
)
assert result2.text is not None
async def test_mental_model_response_includes_tags(self, memory_with_mission_and_tags, request_context):
"""Test that mental model responses include the tags field."""
memory, bank_id = memory_with_mission_and_tags
# Create models with tags
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["test-tag"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Verify tags field is present in response
for model in models:
assert "tags" in model
assert isinstance(model["tags"], list)
# Get single model
if models:
model = await memory.get_mental_model(
bank_id=bank_id,
model_id=models[0]["id"],
request_context=request_context,
)
assert "tags" in model
assert isinstance(model["tags"], list)
+246 -186
View File
@@ -1,5 +1,9 @@
"""
Test observation generation and entity state functionality.
NOTE: Observations are now stored as summaries on the entities table,
not as separate memory_units. The observations list in EntityState is
populated from the summary for backwards compatibility.
"""
import pytest
from hindsight_api.engine.memory_engine import Budget
@@ -8,21 +12,16 @@ from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_observation_generation_on_put(memory, request_context):
async def test_entity_extraction_on_retain(memory, request_context):
"""
Test that observations are generated SYNCHRONOUSLY when new facts are added.
Test that entities are extracted when new facts are added.
Observations are generated during retain when:
- Entity has >= 5 facts (MIN_FACTS_THRESHOLD)
- Entity is in top 5 by mention count
This test stores enough facts to trigger automatic observation generation.
This test stores multiple facts and verifies entities are extracted.
"""
bank_id = f"test_obs_{datetime.now(timezone.utc).timestamp()}"
bank_id = f"test_entity_extraction_{datetime.now(timezone.utc).timestamp()}"
try:
# Store multiple facts about John to reach the MIN_FACTS_THRESHOLD (5)
# Each retain call should extract at least one fact about John
# Store multiple facts about John
contents = [
"John is a software engineer at Google.",
"John is detail-oriented and methodical in his work.",
@@ -41,9 +40,8 @@ async def test_observation_generation_on_put(memory, request_context):
request_context=request_context,
)
# Observations are generated SYNCHRONOUSLY during retain,
# so they should be available immediately after retain completes.
# No need to wait for background tasks for observations.
# Wait for background tasks
await memory.wait_for_background_tasks()
# Find the John entity
pool = await memory._get_pool()
@@ -58,7 +56,7 @@ async def test_observation_generation_on_put(memory, request_context):
bank_id
)
# Also check the fact count for this entity
# Check the fact count for this entity
if entity_row:
fact_count = await conn.fetchval(
"""
@@ -70,30 +68,9 @@ async def test_observation_generation_on_put(memory, request_context):
print(f"Entity: {entity_row['canonical_name']} has {fact_count} linked facts")
assert entity_row is not None, "John entity should have been extracted"
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
print(f"\n=== Found Entity ===")
print(f"Entity: {entity_name} (id: {entity_id})")
# Get observations for the entity - should be available immediately
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"\n=== Observations for {entity_name} ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Verify observations were created (requires >= 5 facts)
assert len(observations) > 0, \
f"Observations should have been generated synchronously during retain (entity has {fact_count} facts, threshold is 5)"
# Check that observations mention relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "ai", "machine learning", "detail"]), \
"Observations should contain relevant information about John"
print(f"✓ Observations were successfully generated synchronously during retain")
print(f"Entity: {entity_row['canonical_name']} (id: {entity_row['id']})")
print(f"Entity was successfully extracted")
finally:
# Cleanup
@@ -106,7 +83,7 @@ async def test_observation_generation_on_put(memory, request_context):
@pytest.mark.asyncio
async def test_regenerate_entity_observations(memory, request_context):
"""
Test explicit regeneration of observations for an entity.
Test explicit regeneration of summary for an entity.
"""
bank_id = f"test_regen_obs_{datetime.now(timezone.utc).timestamp()}"
@@ -139,7 +116,7 @@ async def test_regenerate_entity_observations(memory, request_context):
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Manually regenerate observations
# Manually regenerate summary (via observations API for backwards compat)
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
@@ -147,23 +124,25 @@ async def test_regenerate_entity_observations(memory, request_context):
request_context=request_context,
)
print(f"\n=== Regenerated Observations ===")
print(f"Created {len(created_ids)} observations for {entity_name}")
print(f"\n=== Regenerated Summary ===")
print(f"Created {len(created_ids)} summary for {entity_name}")
# Get the observations
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
for obs in observations:
# Get entity state
state = await memory.get_entity_state(
bank_id, entity_id, entity_name, request_context=request_context
)
for obs in state.observations:
print(f" - {obs.text}")
# Verify observations were created
# Verify summary was created
if len(created_ids) > 0:
assert len(observations) == len(created_ids), "Should have same number of observations as created IDs"
print(f"✓ Observations regenerated successfully")
assert len(state.observations) == 1, "Should have exactly 1 observation (the summary)"
print(f"Summary regenerated successfully")
else:
print(f"Note: No observations were regenerated")
print(f"Note: No summary was regenerated")
else:
print(f"Note: No 'Sarah' entity was extracted")
print(f"Note: No 'Sarah' entity was extracted")
finally:
# Cleanup
@@ -174,19 +153,14 @@ async def test_regenerate_entity_observations(memory, request_context):
@pytest.mark.asyncio
async def test_manual_regenerate_with_few_facts(memory, request_context):
async def test_entity_state_retrieval(memory, request_context):
"""
Test that manual regeneration works even with fewer than 5 facts.
This is important because:
- Automatic generation during retain requires MIN_FACTS_THRESHOLD (5)
- But manual regeneration via API should work with any number of facts
- The UI triggers manual regeneration, so it should work regardless of fact count
Test retrieving entity state with facts.
"""
bank_id = f"test_manual_regen_{datetime.now(timezone.utc).timestamp()}"
bank_id = f"test_entity_state_{datetime.now(timezone.utc).timestamp()}"
try:
# Store only 2 facts - below the automatic threshold
# Store facts
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a senior software engineer.",
@@ -220,51 +194,25 @@ async def test_manual_regenerate_with_few_facts(memory, request_context):
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Check fact count - should be < 5
# Check fact count
async with pool.acquire() as conn:
fact_count = await conn.fetchval(
"SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1",
entity_row['id']
)
print(f"\n=== Manual Regeneration Test ===")
print(f"\n=== Entity State Test ===")
print(f"Entity: {entity_name} (id: {entity_id})")
print(f"Linked facts: {fact_count}")
# Verify we're testing with fewer than the automatic threshold
assert fact_count < 5, f"Test requires < 5 facts, but entity has {fact_count}"
# Before regeneration - should have no observations (auto threshold not met)
obs_before = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"Observations before manual regenerate: {len(obs_before)}")
# Manually regenerate observations - this should work regardless of fact count
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
request_context=request_context,
# Get entity state
state = await memory.get_entity_state(
bank_id, entity_id, entity_name, request_context=request_context
)
print(f"Observations created by manual regenerate: {len(created_ids)}")
# Get observations after regeneration
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"Observations after manual regenerate: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Manual regeneration should create observations even with < 5 facts
assert len(observations) > 0, \
f"Manual regeneration should create observations even with only {fact_count} facts. " \
f"The LLM should synthesize at least 1 observation from the available facts."
# Verify observations contain relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "hiking", "photography", "alice"]), \
"Observations should contain relevant information about Alice"
print(f"✓ Manual regeneration works with {fact_count} facts (below automatic threshold of 5)")
assert state.entity_id == entity_id
assert state.canonical_name == entity_name
print(f"Entity state retrieved successfully")
finally:
# Cleanup
@@ -277,16 +225,16 @@ async def test_manual_regenerate_with_few_facts(memory, request_context):
@pytest.mark.asyncio
async def test_search_with_include_entities(memory, request_context):
"""
Test that search with include_entities=True returns entity observations.
Test that search with include_entities=True returns entity information.
This test verifies that:
1. Observations are generated during retain (when entity has >= 5 facts)
2. Observations are returned in recall results with include_entities=True
1. Entities are extracted after retain
2. Entity info is returned in recall results with include_entities=True
"""
bank_id = f"test_search_ent_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough facts about Alice to trigger observation generation (>= 5 facts)
# Store facts about Alice
contents = [
"Alice is a data scientist who works on recommendation systems at Netflix.",
"Alice presented her research at the ML conference last month.",
@@ -305,7 +253,8 @@ async def test_search_with_include_entities(memory, request_context):
request_context=request_context,
)
# Observations are generated synchronously during retain, no need to wait
# Wait for background tasks
await memory.wait_for_background_tasks()
# Search with include_entities=True
result = await memory.recall_async(
@@ -315,7 +264,7 @@ async def test_search_with_include_entities(memory, request_context):
budget=Budget.LOW,
max_tokens=2000,
include_entities=True,
max_entity_tokens=500,
max_entity_tokens=5000,
request_context=request_context,
)
@@ -326,40 +275,28 @@ async def test_search_with_include_entities(memory, request_context):
if fact.entities:
print(f" Entities: {', '.join(fact.entities)}")
print(f"\n=== Entity Observations in Recall ===")
if result.entities:
for name, state in result.entities.items():
print(f"\n{name}:")
for obs in state.observations:
print(f" - {obs.text}")
else:
print("No entity observations returned")
# Verify results
assert len(result.results) > 0, "Should find some facts"
# Check if entities are included in facts
facts_with_entities = [f for f in result.results if f.entities]
assert len(facts_with_entities) > 0, "Some facts should have entity information"
print(f"{len(facts_with_entities)} facts have entity information")
print(f"{len(facts_with_entities)} facts have entity information")
# Check if entity observations are included in recall
assert result.entities is not None and len(result.entities) > 0, \
"Entity observations should be included in recall results"
print(f"✓ Entity observations included for {len(result.entities)} entities")
# Check if entity info is returned
if result.entities:
print(f"Entity info included for {len(result.entities)} entities")
# Verify Alice entity has observations
alice_found = False
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
if "alice" in name.lower():
alice_found = True
assert len(state.observations) > 0, \
"Alice should have observations (generated during retain)"
print(f"✓ Alice has {len(state.observations)} observations in recall result")
# Verify Alice entity is in results
alice_found = False
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
if "alice" in name.lower():
alice_found = True
print(f"Alice entity found: {name}")
assert alice_found, "Alice entity should be in recall results"
assert alice_found, "Alice entity should be in recall results"
finally:
# Cleanup
@@ -435,7 +372,10 @@ async def test_get_entity_state(memory, request_context):
@pytest.mark.asyncio
async def test_observation_fact_type_in_database(memory, request_context):
"""
Test that observations are stored with correct fact_type in database.
Test that observations are NOT stored as memory_units with fact_type='observation'.
NOTE: Observations are now handled via mental models, not as memory_units
or entity summaries.
"""
bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}"
@@ -451,7 +391,7 @@ async def test_observation_fact_type_in_database(memory, request_context):
await memory.wait_for_background_tasks()
# Check that observations have correct fact_type
# Check that NO observations exist in memory_units
pool = await memory._get_pool()
async with pool.acquire() as conn:
observations = await conn.fetch(
@@ -463,17 +403,11 @@ async def test_observation_fact_type_in_database(memory, request_context):
bank_id
)
print(f"\n=== Observation Records in Database ===")
print(f"Found {len(observations)} observation records")
for obs in observations:
print(f" - fact_type: {obs['fact_type']}")
print(f" text: {obs['text']}")
print(f" context: {obs['context']}")
print(f"\n=== Observation Records in memory_units ===")
print(f"Found {len(observations)} observation records (should be 0)")
if len(observations) > 0:
for obs in observations:
assert obs['fact_type'] == 'observation', "All observation records should have fact_type='observation'"
print(f"✓ All observations have correct fact_type")
# Observations are no longer stored as memory_units
assert len(observations) == 0, "Observations should NOT be stored as memory_units"
finally:
# Cleanup
@@ -484,23 +418,183 @@ async def test_observation_fact_type_in_database(memory, request_context):
@pytest.mark.asyncio
async def test_user_entity_prioritized_for_observations(memory, request_context):
async def test_entity_mention_counts(memory, request_context):
"""
Test that the 'user' entity gets observations even when many other entities exist.
Test that entity mention counts are tracked correctly.
The retain pipeline only regenerates observations for TOP_N_ENTITIES (5) entities,
sorted by mention count. This test verifies that the most mentioned entity ('user')
gets prioritized and receives observations.
This is critical because 'user' is often the most important entity in personal memory.
This test creates entities with varying mention counts and verifies
that the counts are accurate.
"""
bank_id = f"test_user_priority_{datetime.now(timezone.utc).timestamp()}"
bank_id = f"test_mention_counts_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content where 'user' (the user) is mentioned many times
# along with several other entities
# Create content with varying entity mention counts:
# - "HighMention Corp" mentioned 10+ times
# - "LowMention Ltd" mentioned 1 time
contents = [
# High mentions - HighMention Corp
"HighMention Corp is a tech company based in San Francisco.",
"HighMention Corp was founded in 2010 by experienced entrepreneurs.",
"HighMention Corp has over 500 employees worldwide.",
"HighMention Corp specializes in cloud computing solutions.",
"HighMention Corp recently raised $50 million in Series C funding.",
"HighMention Corp has partnerships with major tech companies.",
"HighMention Corp is known for its innovative culture.",
"HighMention Corp offers competitive salaries and benefits.",
"HighMention Corp has offices in 5 countries.",
"HighMention Corp won the best workplace award last year.",
# Low mentions - LowMention Ltd
"LowMention Ltd is a small consulting firm.",
]
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="company info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
)
# Wait for background tasks
await memory.wait_for_background_tasks()
# Check entity mention counts
pool = await memory._get_pool()
async with pool.acquire() as conn:
entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name, e.mention_count
FROM entities e
WHERE e.bank_id = $1
ORDER BY e.mention_count DESC
""",
bank_id
)
print(f"\n=== Entity Mention Counts Test ===")
print(f"Total entities: {len(entities)}")
high_mention_entity = None
low_mention_entity = None
for entity in entities:
name = entity['canonical_name'].lower()
mention_count = entity['mention_count']
print(f" {entity['canonical_name']}: mentions={mention_count}")
if "highmention" in name:
high_mention_entity = entity
elif "lowmention" in name:
low_mention_entity = entity
# Verify HighMention Corp has higher mention count
if high_mention_entity and low_mention_entity:
assert high_mention_entity['mention_count'] > low_mention_entity['mention_count'], \
"HighMention Corp should have more mentions than LowMention Ltd"
print("PASS: Entity mention counts are tracked correctly")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_entity_mention_ranking(memory, request_context):
"""
Test that entity mention counts correctly rank entities.
This test:
1. Creates an entity with 6 mentions
2. Adds more entities with higher mention counts
3. Verifies entities are ranked correctly by mention count
"""
bank_id = f"test_ranking_{datetime.now(timezone.utc).timestamp()}"
try:
# Phase 1: Create "OriginalEntity" with 6 mentions
print("\n=== Phase 1: Create OriginalEntity with 6 mentions ===")
for i in range(6):
await memory.retain_async(
bank_id=bank_id,
content=f"OriginalEntity is mentioned here in fact {i+1}.",
context="test",
event_date=datetime(2024, 1, 1 + i, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Phase 2: Add more entities with MORE mentions
print("\n=== Phase 2: Add entities with 10+ mentions each ===")
for entity_num in range(3): # Reduced from 10 to 3 to speed up test
entity_name = f"NewEntity{entity_num}"
for mention in range(10):
await memory.retain_async(
bank_id=bank_id,
content=f"{entity_name} is a very important entity, mention {mention+1}.",
context="test",
event_date=datetime(2024, 2, 1 + mention, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Phase 3: Verify entities are ranked by mention count
print("\n=== Phase 3: Check entity ranking ===")
pool = await memory._get_pool()
async with pool.acquire() as conn:
all_entities = await conn.fetch(
"""
SELECT canonical_name, mention_count
FROM entities
WHERE bank_id = $1
ORDER BY mention_count DESC
""",
bank_id
)
print(f"\nAll entities by mention count:")
for e in all_entities:
print(f" {e['canonical_name']}: mentions={e['mention_count']}")
# Verify new entities have higher counts than OriginalEntity
original = next((e for e in all_entities if 'originalentity' in e['canonical_name'].lower()), None)
new_entities = [e for e in all_entities if 'newentity' in e['canonical_name'].lower()]
assert original is not None, "OriginalEntity should exist"
assert len(new_entities) > 0, "NewEntity entities should exist"
# Verify entities are created and have mention counts
# Note: LLM may merge mentions, so we just check that new entities exist
print(f"OriginalEntity mentions: {original['mention_count']}")
for new_entity in new_entities:
print(f"{new_entity['canonical_name']} mentions: {new_entity['mention_count']}")
print("PASS: Entities are created with mention counts tracked")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_user_entity_extraction(memory, request_context):
"""
Test that the 'user' entity is correctly extracted when mentioned frequently.
"""
bank_id = f"test_user_entity_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content where 'user' is mentioned many times
contents = [
# User mentioned frequently
"The user loves hiking in the mountains during summer.",
"The user works as a software engineer at Microsoft.",
"The user has a dog named Max who is a golden retriever.",
@@ -510,11 +604,8 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
# Other entities mentioned fewer times
"Sarah is a friend who works at Google.",
"Bob is a colleague from the data science team.",
"Tokyo is a city the user visited last year.",
"Python is the user's favorite programming language.",
]
# Retain all content in a single batch for efficiency
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
@@ -524,12 +615,12 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
request_context=request_context,
)
# Observations are generated synchronously during retain
# Wait for background tasks
await memory.wait_for_background_tasks()
# Find the 'user' entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
# Find user entity (may be named "user", "the user", etc.)
user_entity = await conn.fetchrow(
"""
SELECT e.id, e.canonical_name,
@@ -544,7 +635,7 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
bank_id
)
# Get all entities with their fact counts to verify prioritization
# Get all entities with their fact counts
all_entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name,
@@ -564,41 +655,10 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
# Verify user entity exists
assert user_entity is not None, "User entity should have been extracted"
user_entity_id = str(user_entity['id'])
user_entity_name = user_entity['canonical_name']
user_fact_count = user_entity['fact_count']
print(f"\n=== User Entity ===")
print(f"Entity: {user_entity_name} (id: {user_entity_id})")
print(f"Fact count: {user_fact_count}")
# Verify user has enough facts for observations (>= MIN_FACTS_THRESHOLD of 5)
assert user_fact_count >= 5, \
f"User entity should have at least 5 facts, but has {user_fact_count}"
# Get observations for user entity
observations = await memory.get_entity_observations(bank_id, user_entity_id, limit=10, request_context=request_context)
print(f"\n=== User Entity Observations ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Verify observations were generated for user (critical assertion)
assert len(observations) > 0, \
f"User entity should have observations (has {user_fact_count} facts, threshold is 5). " \
f"This may indicate that 'user' is not being prioritized in the top 5 entities by mention count."
# Verify observations mention relevant content about the user
obs_texts = " ".join([o.text.lower() for o in observations])
user_keywords = ["hiking", "software", "engineer", "dog", "max", "cooking",
"italian", "mit", "dune", "microsoft"]
matching_keywords = [k for k in user_keywords if k in obs_texts]
assert len(matching_keywords) > 0, \
f"Observations should contain relevant information about the user. Keywords found: {matching_keywords}"
print(f"✓ User entity was prioritized and received {len(observations)} observations")
print(f"✓ Observations contain relevant keywords: {matching_keywords}")
print(f"Entity: {user_entity['canonical_name']} (id: {user_entity['id']})")
print(f"Fact count: {user_entity['fact_count']}")
print(f"User entity was successfully extracted")
finally:
# Cleanup
+983
View File
@@ -0,0 +1,983 @@
"""Tests for the reflect agent and its tools."""
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api.engine.reflect.agent import run_reflect_agent
from hindsight_api.engine.reflect.models import (
AnswerSection,
MentalModelInput,
MentalModelObservation,
ReflectAction,
ReflectActionBatch,
ReflectAgentResult,
)
from hindsight_api.engine.reflect.tools import (
generate_model_id,
tool_expand,
tool_learn,
tool_lookup,
tool_recall,
)
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult
class TestGenerateModelId:
"""Test model ID generation."""
def test_basic_name(self):
"""Test simple name conversion."""
assert generate_model_id("My Model") == "my-model"
def test_special_characters(self):
"""Test name with special characters."""
assert generate_model_id("Alice's Project (2024)") == "alice-s-project-2024"
def test_truncation(self):
"""Test long name truncation."""
long_name = "A" * 100
result = generate_model_id(long_name)
assert len(result) <= 50
def test_leading_trailing_hyphens(self):
"""Test that leading/trailing hyphens are stripped."""
assert generate_model_id("--Test--") == "test"
class TestToolLookup:
"""Test the lookup tool."""
@pytest.fixture
def mock_conn(self):
"""Create a mock database connection."""
conn = AsyncMock()
return conn
async def test_list_all_models(self, mock_conn):
"""Test listing all mental models (compact: id, name, description only)."""
mock_conn.fetch.return_value = [
{
"id": "model-1",
"subtype": "learned",
"name": "Model 1",
"description": "First model",
},
{
"id": "model-2",
"subtype": "structural",
"name": "Model 2",
"description": "Second model",
},
]
result = await tool_lookup(mock_conn, "test-bank")
assert result["count"] == 2
assert len(result["models"]) == 2
assert result["models"][0]["id"] == "model-1"
assert result["models"][0]["name"] == "Model 1"
assert "observation_titles" not in result["models"][0] # No observation_titles in list view
assert result["models"][1]["id"] == "model-2"
async def test_get_specific_model(self, mock_conn):
"""Test getting a specific mental model."""
mock_conn.fetchrow.return_value = {
"id": "model-1",
"subtype": "learned",
"name": "Model 1",
"description": "First model",
"observations": {"observations": [{"title": "Overview", "text": "Full summary of model 1", "memory_ids": ["mem-1", "mem-2"]}]},
"entity_id": None,
"last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"),
}
result = await tool_lookup(mock_conn, "test-bank", "model-1")
assert result["found"] is True
assert result["model"]["id"] == "model-1"
assert len(result["model"]["observations"]) == 1
assert result["model"]["observations"][0]["text"] == "Full summary of model 1"
# Verify memory_ids are mapped to based_on
assert result["model"]["observations"][0]["based_on"] == ["mem-1", "mem-2"]
async def test_model_not_found(self, mock_conn):
"""Test looking up non-existent model."""
mock_conn.fetchrow.return_value = None
result = await tool_lookup(mock_conn, "test-bank", "non-existent")
assert result["found"] is False
assert result["model_id"] == "non-existent"
class TestToolLearn:
"""Test the learn tool.
The learn tool creates placeholder mental models with name/description only.
Actual content is generated in the background via refresh.
"""
@pytest.fixture
def mock_conn(self):
"""Create a mock database connection."""
conn = AsyncMock()
return conn
async def test_create_new_model(self, mock_conn):
"""Test creating a new mental model placeholder."""
mock_conn.fetchrow.return_value = None # Model doesn't exist
input_model = MentalModelInput(
name="Test Model",
description="A test model to track important patterns",
)
result = await tool_learn(mock_conn, "test-bank", input_model)
assert result["status"] == "created"
assert result["model_id"] == "test-model"
assert result["name"] == "Test Model"
assert result["pending_generation"] is True
mock_conn.execute.assert_called_once()
async def test_update_existing_model(self, mock_conn):
"""Test updating an existing mental model."""
mock_conn.fetchrow.return_value = {"id": "test-model"} # Model exists
input_model = MentalModelInput(
name="Test Model",
description="Updated description",
)
result = await tool_learn(mock_conn, "test-bank", input_model)
assert result["status"] == "updated"
assert result["model_id"] == "test-model"
async def test_learn_with_entity_id(self, mock_conn):
"""Test creating model linked to an entity."""
mock_conn.fetchrow.return_value = None
entity_uuid = str(uuid.uuid4())
input_model = MentalModelInput(
name="Entity Model",
description="Model linked to entity",
entity_id=entity_uuid,
)
result = await tool_learn(mock_conn, "test-bank", input_model)
assert result["status"] == "created"
assert result["pending_generation"] is True
# Verify entity_uuid was passed to the execute call
call_args = mock_conn.execute.call_args
assert uuid.UUID(entity_uuid) in call_args[0]
async def test_learn_creates_empty_observations(self, mock_conn):
"""Test that learn creates model with empty observations (content generated later)."""
mock_conn.fetchrow.return_value = None # Model doesn't exist
input_model = MentalModelInput(
name="Model With Sources",
description="A model to track source facts",
)
result = await tool_learn(mock_conn, "test-bank", input_model)
assert result["status"] == "created"
assert result["pending_generation"] is True
# Verify the INSERT query was called with empty observations
call_args = mock_conn.execute.call_args
# observations should be empty JSON
assert "'{}'::jsonb" in call_args[0][0]
class TestToolExpand:
"""Test the expand tool."""
@pytest.fixture
def mock_conn(self):
"""Create a mock database connection."""
conn = AsyncMock()
return conn
async def test_empty_memory_ids(self, mock_conn):
"""Test expand with empty memory_ids list."""
result = await tool_expand(mock_conn, "test-bank", [], "chunk")
assert "error" in result
assert "memory_ids is required" in result["error"]
async def test_invalid_memory_id(self, mock_conn):
"""Test expand with invalid UUID format."""
result = await tool_expand(mock_conn, "test-bank", ["not-a-uuid"], "chunk")
assert "error" in result
assert "No valid memory IDs provided" in result["error"]
async def test_memory_not_found(self, mock_conn):
"""Test expand with non-existent memory."""
mock_conn.fetch.return_value = [] # No memories found
memory_id = str(uuid.uuid4())
result = await tool_expand(mock_conn, "test-bank", [memory_id], "chunk")
assert "results" in result
assert len(result["results"]) == 1
assert "error" in result["results"][0]
assert "Memory not found" in result["results"][0]["error"]
async def test_expand_to_chunk(self, mock_conn):
"""Test expanding memory to chunk level."""
memory_id = uuid.uuid4()
# Mock batch fetch for memories
mock_conn.fetch.side_effect = [
# First call: get memories
[
{
"id": memory_id,
"text": "Memory text",
"chunk_id": "chunk-1",
"document_id": "doc-1",
"fact_type": "experience",
"context": "some context",
}
],
# Second call: get chunks
[
{
"chunk_id": "chunk-1",
"chunk_text": "Full chunk text with more context",
"chunk_index": 0,
"document_id": "doc-1",
}
],
]
result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "chunk")
assert "results" in result
assert len(result["results"]) == 1
assert result["results"][0]["memory"]["text"] == "Memory text"
assert result["results"][0]["chunk"]["text"] == "Full chunk text with more context"
assert "document" not in result["results"][0] # depth=chunk doesn't include document
async def test_expand_to_document(self, mock_conn):
"""Test expanding memory to document level."""
memory_id = uuid.uuid4()
mock_conn.fetch.side_effect = [
# First call: get memories
[
{
"id": memory_id,
"text": "Memory text",
"chunk_id": "chunk-1",
"document_id": "doc-1",
"fact_type": "experience",
"context": None,
}
],
# Second call: get chunks
[
{
"chunk_id": "chunk-1",
"chunk_text": "Chunk text",
"chunk_index": 0,
"document_id": "doc-1",
}
],
# Third call: get documents
[
{
"id": "doc-1",
"original_text": "Full document text here",
"metadata": {"source": "test"},
"retain_params": {},
}
],
]
result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "document")
assert "results" in result
assert len(result["results"]) == 1
assert "memory" in result["results"][0]
assert "chunk" in result["results"][0]
assert "document" in result["results"][0]
assert result["results"][0]["document"]["full_text"] == "Full document text here"
async def test_expand_multiple_memories(self, mock_conn):
"""Test expanding multiple memories in a single batch."""
memory_id_1 = uuid.uuid4()
memory_id_2 = uuid.uuid4()
mock_conn.fetch.side_effect = [
# First call: get memories
[
{
"id": memory_id_1,
"text": "Memory 1",
"chunk_id": "chunk-1",
"document_id": "doc-1",
"fact_type": "experience",
"context": None,
},
{
"id": memory_id_2,
"text": "Memory 2",
"chunk_id": "chunk-2",
"document_id": "doc-1",
"fact_type": "world",
"context": None,
},
],
# Second call: get chunks
[
{
"chunk_id": "chunk-1",
"chunk_text": "Chunk 1 text",
"chunk_index": 0,
"document_id": "doc-1",
},
{
"chunk_id": "chunk-2",
"chunk_text": "Chunk 2 text",
"chunk_index": 1,
"document_id": "doc-1",
},
],
]
result = await tool_expand(mock_conn, "test-bank", [str(memory_id_1), str(memory_id_2)], "chunk")
assert "results" in result
assert result["count"] == 2
assert result["results"][0]["memory"]["text"] == "Memory 1"
assert result["results"][1]["memory"]["text"] == "Memory 2"
class TestToolRecall:
"""Test the recall tool."""
async def test_recall_returns_memories(self):
"""Test recall searches and returns memories."""
mock_engine = AsyncMock()
mock_result = MagicMock()
mock_result.results = [
MagicMock(
id=uuid.uuid4(),
text="Memory 1",
fact_type="experience",
entities=["Alice"],
occurred_start="2024-01-01",
),
MagicMock(
id=uuid.uuid4(),
text="Memory 2",
fact_type="world",
entities=None,
occurred_start=None,
),
]
mock_engine.recall_async.return_value = mock_result
mock_request_context = MagicMock()
result = await tool_recall(mock_engine, "test-bank", "test query", mock_request_context)
assert result["query"] == "test query"
assert result["count"] == 2
assert len(result["memories"]) == 2
assert result["memories"][0]["text"] == "Memory 1"
assert result["memories"][0]["entities"] == ["Alice"]
# Verify recall_async was called with correct params
mock_engine.recall_async.assert_called_once()
call_kwargs = mock_engine.recall_async.call_args[1]
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["query"] == "test query"
assert call_kwargs["fact_type"] == ["experience", "world"] # No opinions
class TestPromptSize:
"""Test that prompts stay within reasonable size limits.
Large prompts cause slow LLM responses (120s+ observed in production).
The agent should not pre-load all mental models; use lookup() instead.
"""
def test_initial_prompt_is_small(self):
"""Verify the initial prompt (no tool history) is reasonably small."""
from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools
# Typical bank profile
bank_profile = {
"name": "Test Assistant",
"mission": "A helpful assistant for tracking engineering team activities. Help the team stay organized and informed.",
}
# First iteration: no context history
context_history: list[dict] = []
query = "Who should take ownership of storing load test scripts in Git?"
# No additional context (mental models not pre-loaded)
prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None)
system_prompt = build_system_prompt_for_tools(bank_profile)
total_chars = len(prompt) + len(system_prompt)
estimated_tokens = total_chars // 4 # Rough estimate
# Initial prompt should be under 3000 tokens (~12k chars)
# This ensures fast LLM responses on the first iteration
assert total_chars < 12000, f"Initial prompt too large: {total_chars} chars (~{estimated_tokens} tokens)"
assert estimated_tokens < 3000, f"Initial prompt too large: ~{estimated_tokens} tokens"
def test_prompt_with_tool_history_grows_reasonably(self):
"""Verify prompts grow reasonably with tool results."""
from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools
bank_profile = {
"name": "Test Assistant",
"mission": "A helpful assistant. Help the team.",
}
# Simulate recall result with 50 memories (realistic scenario)
recall_result = {
"query": "test query",
"count": 50,
"memories": [
{"id": f"mem-{i}", "text": f"This is memory number {i} with some content.", "type": "experience"}
for i in range(50)
],
}
context_history = [{"tool": "recall", "input": {"query": "test"}, "output": recall_result}]
query = "What do you know about the team?"
prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None)
system_prompt = build_system_prompt_for_tools(bank_profile)
total_chars = len(prompt) + len(system_prompt)
estimated_tokens = total_chars // 4
# With tool results, prompt should still be manageable (<20k tokens)
assert total_chars < 80000, f"Prompt with tools too large: {total_chars} chars (~{estimated_tokens} tokens)"
class TestReflectAgent:
"""Test the reflect agent loop with native tool calling."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM provider."""
llm = AsyncMock()
return llm
@pytest.fixture
def bank_profile(self):
"""Create a test bank profile."""
return {
"name": "Test Assistant",
"mission": "A helpful test assistant. Help with testing.",
}
@pytest.fixture
def mock_tools(self):
"""Create mock tool callbacks."""
# Include memory IDs in recall results so guardrail passes
memory_id = str(uuid.uuid4())
return {
"lookup_fn": AsyncMock(return_value={"count": 0, "models": []}),
"recall_fn": AsyncMock(return_value={
"query": "test",
"count": 1,
"memories": [{"id": memory_id, "text": "Memory", "type": "experience"}]
}),
"learn_fn": AsyncMock(return_value={"status": "created", "model_id": "new-model"}),
"expand_fn": AsyncMock(return_value={
"results": [{"memory_id": "123", "memory": {"id": "123", "text": "Memory text"}}],
"count": 1
}),
}
def _make_tool_result(self, tool_calls: list[dict]) -> LLMToolCallResult:
"""Helper to create LLMToolCallResult from tool call dicts."""
return LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"call_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(tool_calls)
],
finish_reason="tool_calls",
)
async def test_agent_done_immediately_rejected_by_guardrail(self, mock_llm, bank_profile, mock_tools):
"""Test that guardrail rejects done without evidence gathering."""
# First call: agent tries to return done immediately (rejected by guardrail)
# Second call: agent gathers evidence
# Third call: agent returns done with evidence
mock_llm.call_with_tools.side_effect = [
self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]),
# After guardrail rejection, agent should gather evidence
self._make_tool_result([{"name": "recall", "arguments": {"query": "test query"}}]),
# Now with evidence, done is accepted
self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="What is the answer?",
bank_profile=bank_profile,
**mock_tools,
)
assert isinstance(result, ReflectAgentResult)
assert result.text == "The answer is 42."
# 3 iterations: rejected done, recall, accepted done
assert result.iterations == 3
# Tools called: list_mental_models (auto at start of each iteration) + recall
# The exact count may vary based on implementation
assert result.tools_called >= 1 # At least recall was called
async def test_agent_calls_tools_then_done(self, mock_llm, bank_profile, mock_tools):
"""Test agent that calls tools before completing."""
# First call: lookup and recall
# Second call: done
mock_llm.call_with_tools.side_effect = [
self._make_tool_result([
{"name": "list_mental_models", "arguments": {}},
{"name": "recall", "arguments": {"query": "test query"}},
]),
self._make_tool_result([
{"name": "done", "arguments": {"answer": "Based on my research, the answer is yes."}},
]),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="Is testing important?",
bank_profile=bank_profile,
**mock_tools,
)
assert result.text == "Based on my research, the answer is yes."
assert result.iterations == 2
# Tools called: list_mental_models + recall (+ possibly auto list_mental_models)
assert result.tools_called >= 2
mock_tools["recall_fn"].assert_called_once_with("test query", 2048)
async def test_agent_learns_model(self, mock_llm, bank_profile, mock_tools):
"""Test agent that creates a mental model placeholder."""
mock_tools["learn_fn"].return_value = {"status": "created", "model_id": "new-insight", "pending_generation": True}
mock_llm.call_with_tools.side_effect = [
# First: gather evidence via recall (required by guardrail)
self._make_tool_result([{"name": "recall", "arguments": {"query": "user preferences"}}]),
# Then: learn from the gathered evidence
self._make_tool_result([{
"name": "learn",
"arguments": {
"name": "New Insight",
"description": "Track patterns about user preferences and communication style",
}
}]),
# Finally: done with the learning
self._make_tool_result([{"name": "done", "arguments": {"answer": "I've learned something new."}}]),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="What can you learn?",
bank_profile=bank_profile,
**mock_tools,
)
assert result.mental_models_created == ["new-insight"]
mock_tools["learn_fn"].assert_called_once()
# Verify the learn_fn was called with name and description only
call_args = mock_tools["learn_fn"].call_args
mental_model_arg = call_args[0][0]
assert mental_model_arg.name == "New Insight"
assert "preferences" in mental_model_arg.description
async def test_agent_max_iterations_forces_response(self, mock_llm, bank_profile, mock_tools):
"""Test that max iterations forces a text response."""
# Return tools indefinitely, then final plain text call
mock_llm.call_with_tools.side_effect = [
self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]),
self._make_tool_result([{"name": "recall", "arguments": {"query": "query2"}}]),
]
# On last iteration, LLM.call is used (not call_with_tools)
mock_llm.call.return_value = "Forced final answer after max iterations."
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="Test question",
bank_profile=bank_profile,
max_iterations=3,
**mock_tools,
)
assert result.text == "Forced final answer after max iterations."
assert result.iterations == 3
async def test_agent_handles_tool_error(self, mock_llm, bank_profile, mock_tools):
"""Test agent propagates tool execution errors."""
# Make recall fail
mock_tools["recall_fn"].side_effect = Exception("Database error")
mock_llm.call_with_tools.side_effect = [
self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]),
]
# Tool errors are now propagated as RuntimeError
with pytest.raises(RuntimeError) as exc_info:
await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="Test question",
bank_profile=bank_profile,
**mock_tools,
)
assert "Database error" in str(exc_info.value)
async def test_agent_parallel_tool_calls(self, mock_llm, bank_profile, mock_tools):
"""Test agent executes multiple tools in parallel."""
mock_llm.call_with_tools.side_effect = [
self._make_tool_result([
{"name": "list_mental_models", "arguments": {}},
{"name": "recall", "arguments": {"query": "query1"}},
{"name": "recall", "arguments": {"query": "query2"}},
]),
self._make_tool_result([{"name": "done", "arguments": {"answer": "Done after parallel calls."}}]),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="Test question",
bank_profile=bank_profile,
**mock_tools,
)
# Tools called: list_mental_models + 2x recall (+ possibly auto list_mental_models)
assert result.tools_called >= 3
# recall should be called twice
assert mock_tools["recall_fn"].call_count == 2
async def test_agent_returns_validated_memory_ids(self, mock_llm, bank_profile):
"""Test agent returns only validated memory IDs that were actually recalled."""
memory_id_1 = str(uuid.uuid4())
memory_id_2 = str(uuid.uuid4())
# Mock recall returns these specific memory IDs
mock_recall = AsyncMock(
return_value={
"query": "test",
"count": 2,
"memories": [
{"id": memory_id_1, "text": "Memory 1", "type": "experience"},
{"id": memory_id_2, "text": "Memory 2", "type": "world"},
],
}
)
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[LLMToolCall(
id="call_1",
name="done",
arguments={"answer": "Based on the evidence...", "memory_ids": [memory_id_1, memory_id_2]}
)],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="What do we know?",
bank_profile=bank_profile,
lookup_fn=AsyncMock(return_value={"count": 0, "models": []}),
recall_fn=mock_recall,
expand_fn=AsyncMock(return_value={}),
)
# Both memory IDs should be in the result (they were recalled)
assert memory_id_1 in result.used_memory_ids
assert memory_id_2 in result.used_memory_ids
assert len(result.used_memory_ids) == 2
async def test_agent_filters_hallucinated_memory_ids(self, mock_llm, bank_profile):
"""Test agent filters out memory IDs that were not in recall results."""
valid_memory_id = str(uuid.uuid4())
hallucinated_memory_id = str(uuid.uuid4())
# Mock recall returns only one memory ID
mock_recall = AsyncMock(
return_value={
"query": "test",
"count": 1,
"memories": [
{"id": valid_memory_id, "text": "Real memory", "type": "experience"},
],
}
)
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[LLMToolCall(
id="call_1",
name="done",
arguments={"answer": "Based on evidence...", "memory_ids": [valid_memory_id, hallucinated_memory_id]}
)],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="What do we know?",
bank_profile=bank_profile,
lookup_fn=AsyncMock(return_value={"count": 0, "models": []}),
recall_fn=mock_recall,
expand_fn=AsyncMock(return_value={}),
)
# Only the valid memory ID should be in the result
assert valid_memory_id in result.used_memory_ids
assert hallucinated_memory_id not in result.used_memory_ids
assert len(result.used_memory_ids) == 1
async def test_agent_returns_validated_model_ids(self, mock_llm, bank_profile):
"""Test agent returns only validated model IDs that were actually looked up."""
model_id = "team-structure"
hallucinated_model_id = "non-existent-model"
# Mock lookup returns different results based on input
# - None (or no arg): list_mental_models - returns list of models
# - model_id: get_mental_model - returns specific model with found=True
async def mock_lookup_impl(arg=None):
if arg is None:
return {"count": 1, "models": [{"id": model_id, "name": "Team Structure", "description": "desc"}]}
else:
return {"found": True, "model": {"id": model_id, "name": "Team Structure", "summary": "Full summary"}}
mock_lookup = AsyncMock(side_effect=mock_lookup_impl)
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[
LLMToolCall(id="call_0", name="list_mental_models", arguments={}),
LLMToolCall(id="call_1", name="get_mental_model", arguments={"model_id": model_id}),
],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[LLMToolCall(
id="call_2",
name="done",
arguments={"answer": "Based on team structure...", "model_ids": [model_id, hallucinated_model_id]}
)],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="How is the team organized?",
bank_profile=bank_profile,
lookup_fn=mock_lookup,
recall_fn=AsyncMock(return_value={"query": "test", "count": 0, "memories": []}),
expand_fn=AsyncMock(return_value={}),
)
# Only the valid model ID should be in the result
assert model_id in result.used_model_ids
assert hallucinated_model_id not in result.used_model_ids
async def test_agent_plain_text_answer(self, mock_llm, bank_profile, mock_tools):
"""Test agent with plain text answer format."""
mock_llm.call_with_tools.side_effect = [
# First: gather evidence via recall (required by guardrail)
LLMToolCallResult(
tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "answer"})],
finish_reason="tool_calls",
),
# Then: done with plain text answer
LLMToolCallResult(
tool_calls=[LLMToolCall(
id="call_1",
name="done",
arguments={"answer": "The answer is simple and direct."}
)],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="What's the answer?",
bank_profile=bank_profile,
**mock_tools,
)
assert result.text == "The answer is simple and direct."
@pytest.mark.integration
class TestReflectIntegration:
"""Integration tests for reflect with real database.
These tests require a running database and LLM provider.
Skip with: pytest -m "not integration"
"""
async def test_reflect_creates_learned_mental_model(self, memory, request_context):
"""Test that reflect can create a 'learned' mental model via the agent."""
bank_id = f"test-reflect-{uuid.uuid4().hex[:8]}"
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the team lead and manages the engineering team."},
{"content": "The team has weekly planning meetings on Monday."},
{"content": "Alice prefers asynchronous communication via Slack."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Run reflect - this should use the agentic loop
result = await memory.reflect_async(
bank_id=bank_id,
query="What do you know about Alice and how she manages the team?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Check if any mental models were created (may or may not happen depending on LLM)
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# If models were created, they should be 'learned' subtype
for model in models:
if model.get("subtype") == "learned":
# Learned models are created as placeholders pending generation
assert model.get("name") is not None
assert model.get("description") is not None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_reflect_learn_triggers_background_generation(self, memory, request_context):
"""Test that when reflect calls learn, background generation is triggered.
This test verifies the full flow:
1. Agent decides to learn something important
2. learn tool creates a placeholder model
3. Background generation is automatically triggered
"""
import asyncio
bank_id = f"test-reflect-learn-{uuid.uuid4().hex[:8]}"
# Add rich test data that should prompt the agent to learn something
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Bob is the CEO and founder of the company."},
{"content": "Bob started the company in 2015 after leaving Google."},
{"content": "Bob holds weekly all-hands meetings every Friday at 3pm."},
{"content": "Bob's management style is very hands-off and trusts his team."},
{"content": "Bob prefers face-to-face communication over email."},
{"content": "Bob has a strong focus on company culture and team building."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Run reflect with a query that should prompt learning
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me everything about Bob's leadership style and how he runs the company. "
"This is important information I'll need to reference frequently.",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Wait for any background tasks to complete
await memory.wait_for_background_tasks()
# Give a bit more time for async generation
await asyncio.sleep(2)
# Check if learned models were created
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
learned_models = [m for m in models if m.get("subtype") == "learned"]
# If learned models were created, verify they have proper structure
for model in learned_models:
assert model.get("name") is not None
assert model.get("description") is not None
# After background generation, the model should have been updated
# (observations may or may not be populated depending on timing)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_reflect_excludes_opinions_from_recall(self, memory, request_context):
"""Test that reflect's recall tool doesn't return opinions."""
bank_id = f"test-reflect-no-opinions-{uuid.uuid4().hex[:8]}"
# Add test data (note: we can't directly add opinions since opinion
# extraction was removed, but we can verify recall behavior)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "The weather today is sunny and warm."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Run recall directly to verify it excludes opinions
recall_result = await memory.recall_async(
bank_id=bank_id,
query="weather",
request_context=request_context,
)
# All returned facts should be experience or world, not opinion
for fact in recall_result.results:
assert fact.fact_type in ["experience", "world"]
assert fact.fact_type != "opinion"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
+1 -1
View File
@@ -465,7 +465,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
query="Tell me about Alice",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world", "opinion"],
fact_type=["world", "experience"],
request_context=request_context,
)
+11 -8
View File
@@ -467,9 +467,12 @@ async def test_reflect_with_tags_filters_memories(api_client, test_bank_id):
# The response should mention Oscar's color (blue), not Peter's (red)
# Note: We can check based_on facts if they're returned
if result.get("based_on"):
fact_texts = [f["text"] for f in result["based_on"]]
# Should use Oscar's memory
assert any("Oscar" in t or "blue" in t for t in fact_texts), "Should use Oscar's memory"
based_on = result["based_on"]
memories = based_on.get("memories", []) if isinstance(based_on, dict) else []
fact_texts = [f["text"] for f in memories]
# Should use Oscar's memory (if facts are included)
if fact_texts:
assert any("Oscar" in t or "blue" in t for t in fact_texts), "Should use Oscar's memory"
@pytest.mark.asyncio
@@ -713,15 +716,15 @@ async def test_list_tags_with_wildcard_suffix(api_client):
"""Test that list_tags filters with suffix wildcard pattern (*-admin)."""
bank_id = f"list_tags_suffix_test_{datetime.now().timestamp()}"
# Store memories with various tags
# Store memories with various tags - use meaningful content for reliable fact extraction
response = await api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={
"items": [
{"content": "Admin role memory for super admin.", "tags": ["role-admin"]},
{"content": "Super admin memory about permissions.", "tags": ["super-admin"]},
{"content": "User memory for standard users.", "tags": ["role-user"]},
{"content": "Guest memory for visitors.", "tags": ["role-guest"]},
{"content": "John has the role-admin permission and can manage user accounts.", "tags": ["role-admin"]},
{"content": "Sarah has super-admin access and can modify system settings.", "tags": ["super-admin"]},
{"content": "Mike is a standard role-user who can only view content.", "tags": ["role-user"]},
{"content": "Alice is a role-guest visitor with limited read access.", "tags": ["role-guest"]},
]
}
)
+1
View File
@@ -116,6 +116,7 @@ impl ApiClient {
self.runtime.block_on(async {
let request = types::CreateBankRequest {
name: Some(name.to_string()),
mission: None,
background: None,
disposition: None,
};
+1 -1
View File
@@ -201,7 +201,7 @@ pub fn update_background(
Ok(profile) => {
if output_format == OutputFormat::Pretty {
ui::print_success("Background updated successfully");
println!("\n{}", profile.background);
println!("\n{}", profile.mission);
if !no_update_disposition {
if let (Some(old_p), Some(new_p)) =
+9 -6
View File
@@ -172,8 +172,11 @@ pub fn print_think_response(response: &ReflectResponse) {
println!("{}", response.text);
println!();
if !response.based_on.is_empty() {
println!("{}", dim(&format!("Based on {} memory units", response.based_on.len())));
if let Some(based_on) = &response.based_on {
let count = based_on.memories.len() + based_on.mental_models.len();
if count > 0 {
println!("{}", dim(&format!("Based on {} memory units", count)));
}
}
// Display structured output if present
@@ -322,10 +325,10 @@ pub fn print_disposition(profile: &BankProfileResponse) {
println!("{} {}", dim("Name:"), gradient_start(&profile.name));
println!();
// Print background if available
if !profile.background.is_empty() {
println!("{}", gradient_mid("Background:"));
for line in profile.background.lines() {
// Print mission if available
if !profile.mission.is_empty() {
println!("{}", gradient_mid("Mission:"));
for line in profile.mission.lines() {
println!("{}", line);
}
println!();
@@ -4,6 +4,7 @@ hindsight_client_api/api/banks_api.py
hindsight_client_api/api/documents_api.py
hindsight_client_api/api/entities_api.py
hindsight_client_api/api/memory_api.py
hindsight_client_api/api/mental_models_api.py
hindsight_client_api/api/monitoring_api.py
hindsight_client_api/api/operations_api.py
hindsight_client_api/api_client.py
@@ -12,6 +13,7 @@ hindsight_client_api/configuration.py
hindsight_client_api/exceptions.py
hindsight_client_api/models/__init__.py
hindsight_client_api/models/add_background_request.py
hindsight_client_api/models/async_operation_submit_response.py
hindsight_client_api/models/background_response.py
hindsight_client_api/models/bank_list_item.py
hindsight_client_api/models/bank_list_response.py
@@ -23,6 +25,8 @@ hindsight_client_api/models/chunk_data.py
hindsight_client_api/models/chunk_include_options.py
hindsight_client_api/models/chunk_response.py
hindsight_client_api/models/create_bank_request.py
hindsight_client_api/models/create_mental_model_request.py
hindsight_client_api/models/created_mental_model.py
hindsight_client_api/models/delete_document_response.py
hindsight_client_api/models/delete_response.py
hindsight_client_api/models/disposition_traits.py
@@ -41,19 +45,30 @@ hindsight_client_api/models/list_documents_response.py
hindsight_client_api/models/list_memory_units_response.py
hindsight_client_api/models/list_tags_response.py
hindsight_client_api/models/memory_item.py
hindsight_client_api/models/mental_model_list_response.py
hindsight_client_api/models/mental_model_observation_response.py
hindsight_client_api/models/mental_model_response.py
hindsight_client_api/models/operation_response.py
hindsight_client_api/models/operation_status_response.py
hindsight_client_api/models/operations_list_response.py
hindsight_client_api/models/recall_request.py
hindsight_client_api/models/recall_response.py
hindsight_client_api/models/recall_result.py
hindsight_client_api/models/reflect_based_on.py
hindsight_client_api/models/reflect_fact.py
hindsight_client_api/models/reflect_include_options.py
hindsight_client_api/models/reflect_llm_call.py
hindsight_client_api/models/reflect_mental_model.py
hindsight_client_api/models/reflect_request.py
hindsight_client_api/models/reflect_response.py
hindsight_client_api/models/reflect_tool_call.py
hindsight_client_api/models/reflect_trace.py
hindsight_client_api/models/refresh_mental_models_request.py
hindsight_client_api/models/retain_request.py
hindsight_client_api/models/retain_response.py
hindsight_client_api/models/tag_item.py
hindsight_client_api/models/token_usage.py
hindsight_client_api/models/tool_calls_include_options.py
hindsight_client_api/models/update_disposition_request.py
hindsight_client_api/models/validation_error.py
hindsight_client_api/models/validation_error_loc_inner.py
@@ -21,6 +21,7 @@ from hindsight_client_api.api.banks_api import BanksApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
from hindsight_client_api.api.monitoring_api import MonitoringApi
from hindsight_client_api.api.operations_api import OperationsApi
@@ -37,6 +38,7 @@ from hindsight_client_api.exceptions import ApiException
# import models into sdk package
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_list_item import BankListItem
from hindsight_client_api.models.bank_list_response import BankListResponse
@@ -48,6 +50,8 @@ from hindsight_client_api.models.chunk_data import ChunkData
from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions
from hindsight_client_api.models.chunk_response import ChunkResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.created_mental_model import CreatedMentalModel
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
@@ -66,19 +70,30 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.list_tags_response import ListTagsResponse
from hindsight_client_api.models.memory_item import MemoryItem
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.operation_response import OperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
from hindsight_client_api.models.recall_request import RecallRequest
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel
from hindsight_client_api.models.reflect_request import ReflectRequest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.tag_item import TagItem
from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -5,6 +5,7 @@ from hindsight_client_api.api.banks_api import BanksApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
from hindsight_client_api.api.monitoring_api import MonitoringApi
from hindsight_client_api.api.operations_api import OperationsApi
@@ -64,9 +64,9 @@ class BanksApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> BackgroundResponse:
"""Add/merge memory bank background
"""(Deprecated) Add/merge memory bank background (deprecated)
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Deprecated: Use PUT /mission instead. This endpoint now updates the mission field.
:param bank_id: (required)
:type bank_id: str
@@ -95,6 +95,7 @@ class BanksApi:
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
warnings.warn("POST /v1/default/banks/{bank_id}/background is deprecated.", DeprecationWarning)
_param = self._add_bank_background_serialize(
bank_id=bank_id,
@@ -140,9 +141,9 @@ class BanksApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[BackgroundResponse]:
"""Add/merge memory bank background
"""(Deprecated) Add/merge memory bank background (deprecated)
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Deprecated: Use PUT /mission instead. This endpoint now updates the mission field.
:param bank_id: (required)
:type bank_id: str
@@ -171,6 +172,7 @@ class BanksApi:
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
warnings.warn("POST /v1/default/banks/{bank_id}/background is deprecated.", DeprecationWarning)
_param = self._add_bank_background_serialize(
bank_id=bank_id,
@@ -216,9 +218,9 @@ class BanksApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Add/merge memory bank background
"""(Deprecated) Add/merge memory bank background (deprecated)
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Deprecated: Use PUT /mission instead. This endpoint now updates the mission field.
:param bank_id: (required)
:type bank_id: str
@@ -247,6 +249,7 @@ class BanksApi:
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
warnings.warn("POST /v1/default/banks/{bank_id}/background is deprecated.", DeprecationWarning)
_param = self._add_bank_background_serialize(
bank_id=bank_id,
@@ -372,7 +375,7 @@ class BanksApi:
) -> BankProfileResponse:
"""Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with disposition and mission. Auto-fills missing fields with defaults.
:param bank_id: (required)
:type bank_id: str
@@ -448,7 +451,7 @@ class BanksApi:
) -> ApiResponse[BankProfileResponse]:
"""Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with disposition and mission. Auto-fills missing fields with defaults.
:param bank_id: (required)
:type bank_id: str
@@ -524,7 +527,7 @@ class BanksApi:
) -> RESTResponseType:
"""Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with disposition and mission. Auto-fills missing fields with defaults.
:param bank_id: (required)
:type bank_id: str
@@ -1233,7 +1236,7 @@ class BanksApi:
) -> BankProfileResponse:
"""Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
:param bank_id: (required)
:type bank_id: str
@@ -1305,7 +1308,7 @@ class BanksApi:
) -> ApiResponse[BankProfileResponse]:
"""Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
:param bank_id: (required)
:type bank_id: str
@@ -1377,7 +1380,7 @@ class BanksApi:
) -> RESTResponseType:
"""Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
:param bank_id: (required)
:type bank_id: str
@@ -1754,6 +1757,312 @@ class BanksApi:
@validate_call
async def update_bank(
self,
bank_id: StrictStr,
create_bank_request: CreateBankRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> BankProfileResponse:
"""Partial update memory bank
Partially update an agent's profile. Only provided fields will be updated.
:param bank_id: (required)
:type bank_id: str
:param create_bank_request: (required)
:type create_bank_request: CreateBankRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_serialize(
bank_id=bank_id,
create_bank_request=create_bank_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankProfileResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def update_bank_with_http_info(
self,
bank_id: StrictStr,
create_bank_request: CreateBankRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[BankProfileResponse]:
"""Partial update memory bank
Partially update an agent's profile. Only provided fields will be updated.
:param bank_id: (required)
:type bank_id: str
:param create_bank_request: (required)
:type create_bank_request: CreateBankRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_serialize(
bank_id=bank_id,
create_bank_request=create_bank_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankProfileResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def update_bank_without_preload_content(
self,
bank_id: StrictStr,
create_bank_request: CreateBankRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Partial update memory bank
Partially update an agent's profile. Only provided fields will be updated.
:param bank_id: (required)
:type bank_id: str
:param create_bank_request: (required)
:type create_bank_request: CreateBankRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_serialize(
bank_id=bank_id,
create_bank_request=create_bank_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankProfileResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _update_bank_serialize(
self,
bank_id,
create_bank_request,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
if create_bank_request is not None:
_body_params = create_bank_request
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# set the HTTP header `Content-Type`
if _content_type:
_header_params['Content-Type'] = _content_type
else:
_default_content_type = (
self.api_client.select_header_content_type(
[
'application/json'
]
)
)
if _default_content_type is not None:
_header_params['Content-Type'] = _default_content_type
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='PATCH',
resource_path='/v1/default/banks/{bank_id}',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def update_bank_disposition(
self,
@@ -664,9 +664,9 @@ class EntitiesApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> EntityDetailResponse:
"""Regenerate entity observations
"""(Deprecated) Regenerate entity observations (deprecated)
Regenerate observations for an entity based on all facts mentioning it.
This endpoint is deprecated. Entity observations have been replaced by mental models.
:param bank_id: (required)
:type bank_id: str
@@ -695,6 +695,7 @@ class EntitiesApi:
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
warnings.warn("POST /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate is deprecated.", DeprecationWarning)
_param = self._regenerate_entity_observations_serialize(
bank_id=bank_id,
@@ -740,9 +741,9 @@ class EntitiesApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[EntityDetailResponse]:
"""Regenerate entity observations
"""(Deprecated) Regenerate entity observations (deprecated)
Regenerate observations for an entity based on all facts mentioning it.
This endpoint is deprecated. Entity observations have been replaced by mental models.
:param bank_id: (required)
:type bank_id: str
@@ -771,6 +772,7 @@ class EntitiesApi:
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
warnings.warn("POST /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate is deprecated.", DeprecationWarning)
_param = self._regenerate_entity_observations_serialize(
bank_id=bank_id,
@@ -816,9 +818,9 @@ class EntitiesApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Regenerate entity observations
"""(Deprecated) Regenerate entity observations (deprecated)
Regenerate observations for an entity based on all facts mentioning it.
This endpoint is deprecated. Entity observations have been replaced by mental models.
:param bank_id: (required)
:type bank_id: str
@@ -847,6 +849,7 @@ class EntitiesApi:
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
warnings.warn("POST /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate is deprecated.", DeprecationWarning)
_param = self._regenerate_entity_observations_serialize(
bank_id=bank_id,
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,7 @@ from typing_extensions import Annotated
from pydantic import StrictStr
from typing import Optional
from hindsight_client_api.models.cancel_operation_response import CancelOperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
from hindsight_client_api.api_client import ApiClient, RequestSerialized
@@ -332,6 +333,299 @@ class OperationsApi:
@validate_call
async def get_operation_status(
self,
bank_id: StrictStr,
operation_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> OperationStatusResponse:
"""Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
:param bank_id: (required)
:type bank_id: str
:param operation_id: (required)
:type operation_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_operation_status_serialize(
bank_id=bank_id,
operation_id=operation_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "OperationStatusResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def get_operation_status_with_http_info(
self,
bank_id: StrictStr,
operation_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[OperationStatusResponse]:
"""Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
:param bank_id: (required)
:type bank_id: str
:param operation_id: (required)
:type operation_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_operation_status_serialize(
bank_id=bank_id,
operation_id=operation_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "OperationStatusResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def get_operation_status_without_preload_content(
self,
bank_id: StrictStr,
operation_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
:param bank_id: (required)
:type bank_id: str
:param operation_id: (required)
:type operation_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_operation_status_serialize(
bank_id=bank_id,
operation_id=operation_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "OperationStatusResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _get_operation_status_serialize(
self,
bank_id,
operation_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
if operation_id is not None:
_path_params['operation_id'] = operation_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/v1/default/banks/{bank_id}/operations/{operation_id}',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def list_operations(
self,
@@ -15,6 +15,7 @@
# import models into model package
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_list_item import BankListItem
from hindsight_client_api.models.bank_list_response import BankListResponse
@@ -26,6 +27,8 @@ from hindsight_client_api.models.chunk_data import ChunkData
from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions
from hindsight_client_api.models.chunk_response import ChunkResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.created_mental_model import CreatedMentalModel
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
@@ -44,19 +47,30 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.list_tags_response import ListTagsResponse
from hindsight_client_api.models.memory_item import MemoryItem
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.operation_response import OperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
from hindsight_client_api.models.recall_request import RecallRequest
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel
from hindsight_client_api.models.reflect_request import ReflectRequest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.tag_item import TagItem
from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -24,10 +24,10 @@ from typing_extensions import Self
class AddBackgroundRequest(BaseModel):
"""
Request model for adding/merging background information.
Request model for adding/merging background information. Deprecated: use SetMissionRequest instead.
""" # noqa: E501
content: StrictStr = Field(description="New background information to add or merge")
update_disposition: Optional[StrictBool] = Field(default=True, description="If true, infer disposition traits from the merged background (default: true)")
update_disposition: Optional[StrictBool] = Field(default=True, description="Deprecated - disposition is no longer auto-inferred from mission")
__properties: ClassVar[List[str]] = ["content", "update_disposition"]
model_config = ConfigDict(
@@ -0,0 +1,89 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class AsyncOperationSubmitResponse(BaseModel):
"""
Response model for submitting an async operation.
""" # noqa: E501
operation_id: StrictStr
status: StrictStr
__properties: ClassVar[List[str]] = ["operation_id", "status"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of AsyncOperationSubmitResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of AsyncOperationSubmitResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"operation_id": obj.get("operation_id"),
"status": obj.get("status")
})
return _obj
@@ -25,11 +25,12 @@ from typing_extensions import Self
class BackgroundResponse(BaseModel):
"""
Response model for background update.
Response model for background update. Deprecated: use MissionResponse instead.
""" # noqa: E501
background: StrictStr
mission: StrictStr
background: Optional[StrictStr] = None
disposition: Optional[DispositionTraits] = None
__properties: ClassVar[List[str]] = ["background", "disposition"]
__properties: ClassVar[List[str]] = ["mission", "background", "disposition"]
model_config = ConfigDict(
populate_by_name=True,
@@ -73,6 +74,11 @@ class BackgroundResponse(BaseModel):
# override the default output from pydantic by calling `to_dict()` of disposition
if self.disposition:
_dict['disposition'] = self.disposition.to_dict()
# set to None if background (nullable) is None
# and model_fields_set contains the field
if self.background is None and "background" in self.model_fields_set:
_dict['background'] = None
# set to None if disposition (nullable) is None
# and model_fields_set contains the field
if self.disposition is None and "disposition" in self.model_fields_set:
@@ -90,6 +96,7 @@ class BackgroundResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"mission": obj.get("mission"),
"background": obj.get("background"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None
})
@@ -30,10 +30,10 @@ class BankListItem(BaseModel):
bank_id: StrictStr
name: Optional[StrictStr] = None
disposition: DispositionTraits
background: Optional[StrictStr] = None
mission: Optional[StrictStr] = None
created_at: Optional[StrictStr] = None
updated_at: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "background", "created_at", "updated_at"]
__properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "mission", "created_at", "updated_at"]
model_config = ConfigDict(
populate_by_name=True,
@@ -82,10 +82,10 @@ class BankListItem(BaseModel):
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if background (nullable) is None
# set to None if mission (nullable) is None
# and model_fields_set contains the field
if self.background is None and "background" in self.model_fields_set:
_dict['background'] = None
if self.mission is None and "mission" in self.model_fields_set:
_dict['mission'] = None
# set to None if created_at (nullable) is None
# and model_fields_set contains the field
@@ -112,7 +112,7 @@ class BankListItem(BaseModel):
"bank_id": obj.get("bank_id"),
"name": obj.get("name"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None,
"background": obj.get("background"),
"mission": obj.get("mission"),
"created_at": obj.get("created_at"),
"updated_at": obj.get("updated_at")
})
@@ -17,8 +17,8 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.disposition_traits import DispositionTraits
from typing import Optional, Set
from typing_extensions import Self
@@ -30,8 +30,9 @@ class BankProfileResponse(BaseModel):
bank_id: StrictStr
name: StrictStr
disposition: DispositionTraits
background: StrictStr
__properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "background"]
mission: StrictStr = Field(description="The agent's mission - who they are and what they're trying to accomplish")
background: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "mission", "background"]
model_config = ConfigDict(
populate_by_name=True,
@@ -75,6 +76,11 @@ class BankProfileResponse(BaseModel):
# override the default output from pydantic by calling `to_dict()` of disposition
if self.disposition:
_dict['disposition'] = self.disposition.to_dict()
# set to None if background (nullable) is None
# and model_fields_set contains the field
if self.background is None and "background" in self.model_fields_set:
_dict['background'] = None
return _dict
@classmethod
@@ -90,6 +96,7 @@ class BankProfileResponse(BaseModel):
"bank_id": obj.get("bank_id"),
"name": obj.get("name"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None,
"mission": obj.get("mission"),
"background": obj.get("background")
})
return _obj
@@ -29,8 +29,9 @@ class CreateBankRequest(BaseModel):
""" # noqa: E501
name: Optional[StrictStr] = None
disposition: Optional[DispositionTraits] = None
mission: Optional[StrictStr] = None
background: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "disposition", "background"]
__properties: ClassVar[List[str]] = ["name", "disposition", "mission", "background"]
model_config = ConfigDict(
populate_by_name=True,
@@ -84,6 +85,11 @@ class CreateBankRequest(BaseModel):
if self.disposition is None and "disposition" in self.model_fields_set:
_dict['disposition'] = None
# set to None if mission (nullable) is None
# and model_fields_set contains the field
if self.mission is None and "mission" in self.model_fields_set:
_dict['mission'] = None
# set to None if background (nullable) is None
# and model_fields_set contains the field
if self.background is None and "background" in self.model_fields_set:
@@ -103,6 +109,7 @@ class CreateBankRequest(BaseModel):
_obj = cls.model_validate({
"name": obj.get("name"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None,
"mission": obj.get("mission"),
"background": obj.get("background")
})
return _obj
@@ -0,0 +1,91 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class CreateMentalModelRequest(BaseModel):
"""
Request model for creating a pinned mental model.
""" # noqa: E501
name: StrictStr = Field(description="Human-readable name for the mental model")
description: StrictStr = Field(description="One-liner description for quick scanning")
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility")
__properties: ClassVar[List[str]] = ["name", "description", "tags"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateMentalModelRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateMentalModelRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"description": obj.get("description"),
"tags": obj.get("tags")
})
return _obj
@@ -0,0 +1,91 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class CreatedMentalModel(BaseModel):
"""
A mental model created during reflection.
""" # noqa: E501
id: StrictStr = Field(description="Mental model ID")
name: StrictStr = Field(description="Human-readable name")
description: StrictStr = Field(description="What this model tracks")
__properties: ClassVar[List[str]] = ["id", "name", "description"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreatedMentalModel from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreatedMentalModel from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"description": obj.get("description")
})
return _obj
@@ -0,0 +1,95 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from typing import Optional, Set
from typing_extensions import Self
class MentalModelListResponse(BaseModel):
"""
Response model for listing mental models.
""" # noqa: E501
items: List[MentalModelResponse]
__properties: ClassVar[List[str]] = ["items"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelListResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in items (list)
_items = []
if self.items:
for _item_items in self.items:
if _item_items:
_items.append(_item_items.to_dict())
_dict['items'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelListResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"items": [MentalModelResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None
})
return _obj
@@ -0,0 +1,91 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class MentalModelObservationResponse(BaseModel):
"""
An observation within a mental model with its supporting memories.
""" # noqa: E501
title: StrictStr = Field(description="Observation header (empty for intro)")
text: StrictStr = Field(description="Observation content")
based_on: Optional[List[StrictStr]] = Field(default=None, description="Memory IDs supporting this observation")
__properties: ClassVar[List[str]] = ["title", "text", "based_on"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelObservationResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelObservationResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"title": obj.get("title"),
"text": obj.get("text"),
"based_on": obj.get("based_on")
})
return _obj
@@ -0,0 +1,125 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
from typing import Optional, Set
from typing_extensions import Self
class MentalModelResponse(BaseModel):
"""
Response model for a mental model.
""" # noqa: E501
id: StrictStr
bank_id: StrictStr
subtype: StrictStr
name: StrictStr
description: StrictStr
observations: Optional[List[MentalModelObservationResponse]] = Field(default=None, description="Structured observations with per-observation fact attribution")
entity_id: Optional[StrictStr] = None
links: Optional[List[StrictStr]] = None
tags: Optional[List[StrictStr]] = None
last_updated: Optional[StrictStr] = None
created_at: StrictStr
__properties: ClassVar[List[str]] = ["id", "bank_id", "subtype", "name", "description", "observations", "entity_id", "links", "tags", "last_updated", "created_at"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in observations (list)
_items = []
if self.observations:
for _item_observations in self.observations:
if _item_observations:
_items.append(_item_observations.to_dict())
_dict['observations'] = _items
# set to None if entity_id (nullable) is None
# and model_fields_set contains the field
if self.entity_id is None and "entity_id" in self.model_fields_set:
_dict['entity_id'] = None
# set to None if last_updated (nullable) is None
# and model_fields_set contains the field
if self.last_updated is None and "last_updated" in self.model_fields_set:
_dict['last_updated'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"bank_id": obj.get("bank_id"),
"subtype": obj.get("subtype"),
"name": obj.get("name"),
"description": obj.get("description"),
"observations": [MentalModelObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None,
"entity_id": obj.get("entity_id"),
"links": obj.get("links"),
"tags": obj.get("tags"),
"last_updated": obj.get("last_updated"),
"created_at": obj.get("created_at")
})
return _obj
@@ -29,7 +29,7 @@ class OperationResponse(BaseModel):
id: StrictStr
task_type: StrictStr
items_count: StrictInt
document_id: Optional[StrictStr]
document_id: Optional[StrictStr] = None
created_at: StrictStr
status: StrictStr
error_message: Optional[StrictStr]
@@ -0,0 +1,131 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class OperationStatusResponse(BaseModel):
"""
Response model for getting a single operation status.
""" # noqa: E501
operation_id: StrictStr
status: StrictStr
operation_type: Optional[StrictStr] = None
created_at: Optional[StrictStr] = None
updated_at: Optional[StrictStr] = None
completed_at: Optional[StrictStr] = None
error_message: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["operation_id", "status", "operation_type", "created_at", "updated_at", "completed_at", "error_message"]
@field_validator('status')
def status_validate_enum(cls, value):
"""Validates the enum"""
if value not in set(['pending', 'completed', 'failed', 'not_found']):
raise ValueError("must be one of enum values ('pending', 'completed', 'failed', 'not_found')")
return value
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of OperationStatusResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if operation_type (nullable) is None
# and model_fields_set contains the field
if self.operation_type is None and "operation_type" in self.model_fields_set:
_dict['operation_type'] = None
# set to None if created_at (nullable) is None
# and model_fields_set contains the field
if self.created_at is None and "created_at" in self.model_fields_set:
_dict['created_at'] = None
# set to None if updated_at (nullable) is None
# and model_fields_set contains the field
if self.updated_at is None and "updated_at" in self.model_fields_set:
_dict['updated_at'] = None
# set to None if completed_at (nullable) is None
# and model_fields_set contains the field
if self.completed_at is None and "completed_at" in self.model_fields_set:
_dict['completed_at'] = None
# set to None if error_message (nullable) is None
# and model_fields_set contains the field
if self.error_message is None and "error_message" in self.model_fields_set:
_dict['error_message'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of OperationStatusResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"operation_id": obj.get("operation_id"),
"status": obj.get("status"),
"operation_type": obj.get("operation_type"),
"created_at": obj.get("created_at"),
"updated_at": obj.get("updated_at"),
"completed_at": obj.get("completed_at"),
"error_message": obj.get("error_message")
})
return _obj
@@ -17,7 +17,7 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.operation_response import OperationResponse
from typing import Optional, Set
@@ -28,8 +28,9 @@ class OperationsListResponse(BaseModel):
Response model for list operations endpoint.
""" # noqa: E501
bank_id: StrictStr
total: StrictInt
operations: List[OperationResponse]
__properties: ClassVar[List[str]] = ["bank_id", "operations"]
__properties: ClassVar[List[str]] = ["bank_id", "total", "operations"]
model_config = ConfigDict(
populate_by_name=True,
@@ -90,6 +91,7 @@ class OperationsListResponse(BaseModel):
_obj = cls.model_validate({
"bank_id": obj.get("bank_id"),
"total": obj.get("total"),
"operations": [OperationResponse.from_dict(_item) for _item in obj["operations"]] if obj.get("operations") is not None else None
})
return _obj
@@ -0,0 +1,105 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel
from typing import Optional, Set
from typing_extensions import Self
class ReflectBasedOn(BaseModel):
"""
Evidence the response is based on: memories and mental models.
""" # noqa: E501
memories: Optional[List[ReflectFact]] = Field(default=None, description="Memory facts used to generate the response")
mental_models: Optional[List[ReflectMentalModel]] = Field(default=None, description="Mental models accessed during reflection")
__properties: ClassVar[List[str]] = ["memories", "mental_models"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ReflectBasedOn from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in memories (list)
_items = []
if self.memories:
for _item_memories in self.memories:
if _item_memories:
_items.append(_item_memories.to_dict())
_dict['memories'] = _items
# override the default output from pydantic by calling `to_dict()` of each item in mental_models (list)
_items = []
if self.mental_models:
for _item_mental_models in self.mental_models:
if _item_mental_models:
_items.append(_item_mental_models.to_dict())
_dict['mental_models'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectBasedOn from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"memories": [ReflectFact.from_dict(_item) for _item in obj["memories"]] if obj.get("memories") is not None else None,
"mental_models": [ReflectMentalModel.from_dict(_item) for _item in obj["mental_models"]] if obj.get("mental_models") is not None else None
})
return _obj
@@ -19,6 +19,7 @@ import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from typing import Optional, Set
from typing_extensions import Self
@@ -27,7 +28,8 @@ class ReflectIncludeOptions(BaseModel):
Options for including additional data in reflect results.
""" # noqa: E501
facts: Optional[Dict[str, Any]] = Field(default=None, description="Options for including facts (based_on) in reflect results.")
__properties: ClassVar[List[str]] = ["facts"]
tool_calls: Optional[ToolCallsIncludeOptions] = None
__properties: ClassVar[List[str]] = ["facts", "tool_calls"]
model_config = ConfigDict(
populate_by_name=True,
@@ -68,6 +70,14 @@ class ReflectIncludeOptions(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of tool_calls
if self.tool_calls:
_dict['tool_calls'] = self.tool_calls.to_dict()
# set to None if tool_calls (nullable) is None
# and model_fields_set contains the field
if self.tool_calls is None and "tool_calls" in self.model_fields_set:
_dict['tool_calls'] = None
return _dict
@classmethod
@@ -80,7 +90,8 @@ class ReflectIncludeOptions(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"facts": obj.get("facts")
"facts": obj.get("facts"),
"tool_calls": ToolCallsIncludeOptions.from_dict(obj["tool_calls"]) if obj.get("tool_calls") is not None else None
})
return _obj
@@ -0,0 +1,89 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class ReflectLLMCall(BaseModel):
"""
An LLM call made during reflect agent execution.
""" # noqa: E501
scope: StrictStr = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: StrictInt = Field(description="Execution time in milliseconds")
__properties: ClassVar[List[str]] = ["scope", "duration_ms"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ReflectLLMCall from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectLLMCall from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"scope": obj.get("scope"),
"duration_ms": obj.get("duration_ms")
})
return _obj
@@ -0,0 +1,102 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class ReflectMentalModel(BaseModel):
"""
A mental model accessed during reflect.
""" # noqa: E501
id: StrictStr = Field(description="Mental model ID")
name: StrictStr = Field(description="Mental model name")
type: StrictStr = Field(description="Mental model type: entity, concept, event")
subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned")
description: StrictStr = Field(description="Brief description")
summary: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "description", "summary"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ReflectMentalModel from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if summary (nullable) is None
# and model_fields_set contains the field
if self.summary is None and "summary" in self.model_fields_set:
_dict['summary'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectMentalModel from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"type": obj.get("type"),
"subtype": obj.get("subtype"),
"description": obj.get("description"),
"summary": obj.get("summary")
})
return _obj
@@ -17,9 +17,11 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.created_mental_model import CreatedMentalModel
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.token_usage import TokenUsage
from typing import Optional, Set
from typing_extensions import Self
@@ -29,10 +31,12 @@ class ReflectResponse(BaseModel):
Response model for think endpoint.
""" # noqa: E501
text: StrictStr
based_on: Optional[List[ReflectFact]] = None
based_on: Optional[ReflectBasedOn] = None
structured_output: Optional[Dict[str, Any]] = None
usage: Optional[TokenUsage] = None
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage"]
trace: Optional[ReflectTrace] = None
mental_models_created: Optional[List[CreatedMentalModel]] = Field(default=None, description="Mental models created during this reflection (via the learn tool).")
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage", "trace", "mental_models_created"]
model_config = ConfigDict(
populate_by_name=True,
@@ -73,16 +77,27 @@ class ReflectResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in based_on (list)
_items = []
# override the default output from pydantic by calling `to_dict()` of based_on
if self.based_on:
for _item_based_on in self.based_on:
if _item_based_on:
_items.append(_item_based_on.to_dict())
_dict['based_on'] = _items
_dict['based_on'] = self.based_on.to_dict()
# override the default output from pydantic by calling `to_dict()` of usage
if self.usage:
_dict['usage'] = self.usage.to_dict()
# override the default output from pydantic by calling `to_dict()` of trace
if self.trace:
_dict['trace'] = self.trace.to_dict()
# override the default output from pydantic by calling `to_dict()` of each item in mental_models_created (list)
_items = []
if self.mental_models_created:
for _item_mental_models_created in self.mental_models_created:
if _item_mental_models_created:
_items.append(_item_mental_models_created.to_dict())
_dict['mental_models_created'] = _items
# set to None if based_on (nullable) is None
# and model_fields_set contains the field
if self.based_on is None and "based_on" in self.model_fields_set:
_dict['based_on'] = None
# set to None if structured_output (nullable) is None
# and model_fields_set contains the field
if self.structured_output is None and "structured_output" in self.model_fields_set:
@@ -93,6 +108,11 @@ class ReflectResponse(BaseModel):
if self.usage is None and "usage" in self.model_fields_set:
_dict['usage'] = None
# set to None if trace (nullable) is None
# and model_fields_set contains the field
if self.trace is None and "trace" in self.model_fields_set:
_dict['trace'] = None
return _dict
@classmethod
@@ -106,9 +126,11 @@ class ReflectResponse(BaseModel):
_obj = cls.model_validate({
"text": obj.get("text"),
"based_on": [ReflectFact.from_dict(_item) for _item in obj["based_on"]] if obj.get("based_on") is not None else None,
"based_on": ReflectBasedOn.from_dict(obj["based_on"]) if obj.get("based_on") is not None else None,
"structured_output": obj.get("structured_output"),
"usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None
"usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None,
"trace": ReflectTrace.from_dict(obj["trace"]) if obj.get("trace") is not None else None,
"mental_models_created": [CreatedMentalModel.from_dict(_item) for _item in obj["mental_models_created"]] if obj.get("mental_models_created") is not None else None
})
return _obj
@@ -0,0 +1,100 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class ReflectToolCall(BaseModel):
"""
A tool call made during reflect agent execution.
""" # noqa: E501
tool: StrictStr = Field(description="Tool name: lookup, recall, learn, expand")
input: Dict[str, Any] = Field(description="Tool input parameters")
output: Optional[Dict[str, Any]] = None
duration_ms: StrictInt = Field(description="Execution time in milliseconds")
iteration: Optional[StrictInt] = Field(default=0, description="Iteration number (1-based) when this tool was called")
__properties: ClassVar[List[str]] = ["tool", "input", "output", "duration_ms", "iteration"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ReflectToolCall from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if output (nullable) is None
# and model_fields_set contains the field
if self.output is None and "output" in self.model_fields_set:
_dict['output'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectToolCall from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"tool": obj.get("tool"),
"input": obj.get("input"),
"output": obj.get("output"),
"duration_ms": obj.get("duration_ms"),
"iteration": obj.get("iteration") if obj.get("iteration") is not None else 0
})
return _obj
@@ -0,0 +1,105 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from typing import Optional, Set
from typing_extensions import Self
class ReflectTrace(BaseModel):
"""
Execution trace of LLM and tool calls during reflection.
""" # noqa: E501
tool_calls: Optional[List[ReflectToolCall]] = Field(default=None, description="Tool calls made during reflection")
llm_calls: Optional[List[ReflectLLMCall]] = Field(default=None, description="LLM calls made during reflection")
__properties: ClassVar[List[str]] = ["tool_calls", "llm_calls"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ReflectTrace from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in tool_calls (list)
_items = []
if self.tool_calls:
for _item_tool_calls in self.tool_calls:
if _item_tool_calls:
_items.append(_item_tool_calls.to_dict())
_dict['tool_calls'] = _items
# override the default output from pydantic by calling `to_dict()` of each item in llm_calls (list)
_items = []
if self.llm_calls:
for _item_llm_calls in self.llm_calls:
if _item_llm_calls:
_items.append(_item_llm_calls.to_dict())
_dict['llm_calls'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectTrace from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"tool_calls": [ReflectToolCall.from_dict(_item) for _item in obj["tool_calls"]] if obj.get("tool_calls") is not None else None,
"llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None
})
return _obj
@@ -0,0 +1,109 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class RefreshMentalModelsRequest(BaseModel):
"""
Request model for refresh mental models endpoint.
""" # noqa: E501
tags: Optional[List[StrictStr]] = None
subtype: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["tags", "subtype"]
@field_validator('subtype')
def subtype_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
return value
if value not in set(['structural', 'emergent', 'pinned', 'learned']):
raise ValueError("must be one of enum values ('structural', 'emergent', 'pinned', 'learned')")
return value
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of RefreshMentalModelsRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if tags (nullable) is None
# and model_fields_set contains the field
if self.tags is None and "tags" in self.model_fields_set:
_dict['tags'] = None
# set to None if subtype (nullable) is None
# and model_fields_set contains the field
if self.subtype is None and "subtype" in self.model_fields_set:
_dict['subtype'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of RefreshMentalModelsRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"tags": obj.get("tags"),
"subtype": obj.get("subtype")
})
return _obj
@@ -0,0 +1,87 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class ToolCallsIncludeOptions(BaseModel):
"""
Options for including tool calls in reflect results.
""" # noqa: E501
output: Optional[StrictBool] = Field(default=True, description="Include tool outputs in the trace. Set to false to only include inputs (smaller payload).")
__properties: ClassVar[List[str]] = ["output"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ToolCallsIncludeOptions from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ToolCallsIncludeOptions from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"output": obj.get("output") if obj.get("output") is not None else True
})
return _obj
@@ -189,8 +189,7 @@ class TestReflect:
"""Test reflect with structured output via response_schema.
When response_schema is provided, the response returns structured_output
field parsed according to the provided JSON schema. The text field is empty
since only a single LLM call is made for structured output.
field parsed according to the provided JSON schema.
"""
from typing import Optional
from pydantic import BaseModel
@@ -209,8 +208,6 @@ class TestReflect:
)
assert response is not None
# Text is empty when using structured output (single LLM call)
assert response.text == ""
# Verify structured output is present and can be parsed into model
assert response.structured_output is not None
@@ -510,37 +507,6 @@ class TestEntities:
assert entity is not None
assert entity.id == entity_id
def test_regenerate_entity_observations(self, client, bank_id):
"""Test regenerating observations for an entity."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
async def do_test():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = EntitiesApi(api_client)
# First list entities to get an ID
list_response = await api.list_entities(bank_id=bank_id)
if list_response.items and len(list_response.items) > 0:
entity_id = list_response.items[0].id
# Regenerate observations
result = await api.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
)
return entity_id, result
return None, None
entity_id, result = asyncio.get_event_loop().run_until_complete(do_test())
if entity_id:
assert result is not None
assert result.id == entity_id
class TestDeleteBank:
"""Tests for bank deletion."""
@@ -12,6 +12,9 @@ import type {
ClearBankMemoriesData,
ClearBankMemoriesErrors,
ClearBankMemoriesResponses,
CreateMentalModelData,
CreateMentalModelErrors,
CreateMentalModelResponses,
CreateOrUpdateBankData,
CreateOrUpdateBankErrors,
CreateOrUpdateBankResponses,
@@ -21,6 +24,12 @@ import type {
DeleteDocumentData,
DeleteDocumentErrors,
DeleteDocumentResponses,
DeleteMentalModelData,
DeleteMentalModelErrors,
DeleteMentalModelResponses,
GenerateMentalModelData,
GenerateMentalModelErrors,
GenerateMentalModelResponses,
GetAgentStatsData,
GetAgentStatsErrors,
GetAgentStatsResponses,
@@ -42,6 +51,12 @@ import type {
GetMemoryData,
GetMemoryErrors,
GetMemoryResponses,
GetMentalModelData,
GetMentalModelErrors,
GetMentalModelResponses,
GetOperationStatusData,
GetOperationStatusErrors,
GetOperationStatusResponses,
HealthEndpointHealthGetData,
HealthEndpointHealthGetResponses,
ListBanksData,
@@ -56,6 +71,9 @@ import type {
ListMemoriesData,
ListMemoriesErrors,
ListMemoriesResponses,
ListMentalModelsData,
ListMentalModelsErrors,
ListMentalModelsResponses,
ListOperationsData,
ListOperationsErrors,
ListOperationsResponses,
@@ -70,15 +88,21 @@ import type {
ReflectData,
ReflectErrors,
ReflectResponses,
RefreshMentalModelsData,
RefreshMentalModelsErrors,
RefreshMentalModelsResponses,
RegenerateEntityObservationsData,
RegenerateEntityObservationsErrors,
RegenerateEntityObservationsResponses,
RetainMemoriesData,
RetainMemoriesErrors,
RetainMemoriesResponses,
UpdateBankData,
UpdateBankDispositionData,
UpdateBankDispositionErrors,
UpdateBankDispositionResponses,
UpdateBankErrors,
UpdateBankResponses,
} from "./types.gen";
export type Options<
@@ -282,9 +306,11 @@ export const getEntity = <ThrowOnError extends boolean = false>(
>({ url: "/v1/default/banks/{bank_id}/entities/{entity_id}", ...options });
/**
* Regenerate entity observations
* Regenerate entity observations (deprecated)
*
* Regenerate observations for an entity based on all facts mentioning it.
* This endpoint is deprecated. Entity observations have been replaced by mental models.
*
* @deprecated
*/
export const regenerateEntityObservations = <
ThrowOnError extends boolean = false,
@@ -300,6 +326,113 @@ export const regenerateEntityObservations = <
...options,
});
/**
* List mental models
*
* List all mental models for a bank, optionally filtered by subtype or tags.
*/
export const listMentalModels = <ThrowOnError extends boolean = false>(
options: Options<ListMentalModelsData, ThrowOnError>,
) =>
(options.client ?? client).get<
ListMentalModelsResponses,
ListMentalModelsErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/mental-models", ...options });
/**
* Create mental model
*
* Create a pinned mental model. Pinned models are user-defined and persist across refreshes.
*/
export const createMentalModel = <ThrowOnError extends boolean = false>(
options: Options<CreateMentalModelData, ThrowOnError>,
) =>
(options.client ?? client).post<
CreateMentalModelResponses,
CreateMentalModelErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Delete mental model
*
* Delete a mental model.
*/
export const deleteMentalModel = <ThrowOnError extends boolean = false>(
options: Options<DeleteMentalModelData, ThrowOnError>,
) =>
(options.client ?? client).delete<
DeleteMentalModelResponses,
DeleteMentalModelErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}",
...options,
});
/**
* Get mental model
*
* Get a specific mental model by ID.
*/
export const getMentalModel = <ThrowOnError extends boolean = false>(
options: Options<GetMentalModelData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetMentalModelResponses,
GetMentalModelErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}",
...options,
});
/**
* Refresh mental models (async)
*
* Submit a background job to refresh mental models for a bank. By default refreshes all subtypes. Optionally specify 'subtype' to only refresh 'structural' (from mission) or 'emergent' (from entities) models. Optionally pass tags to apply to newly created models. Use GET /banks/{bank_id}/operations to check progress.
*/
export const refreshMentalModels = <ThrowOnError extends boolean = false>(
options: Options<RefreshMentalModelsData, ThrowOnError>,
) =>
(options.client ?? client).post<
RefreshMentalModelsResponses,
RefreshMentalModelsErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/refresh",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Generate mental model content (async)
*
* Submit a background job to generate/refresh content for a specific mental model. This is useful for newly created learned models or to regenerate content for any model.
*/
export const generateMentalModel = <ThrowOnError extends boolean = false>(
options: Options<GenerateMentalModelData, ThrowOnError>,
) =>
(options.client ?? client).post<
GenerateMentalModelResponses,
GenerateMentalModelErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate",
...options,
});
/**
* List documents
*
@@ -408,10 +541,27 @@ export const cancelOperation = <ThrowOnError extends boolean = false>(
...options,
});
/**
* Get operation status
*
* Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
*/
export const getOperationStatus = <ThrowOnError extends boolean = false>(
options: Options<GetOperationStatusData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetOperationStatusResponses,
GetOperationStatusErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/operations/{operation_id}",
...options,
});
/**
* Get memory bank profile
*
* Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
* Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
*/
export const getBankProfile = <ThrowOnError extends boolean = false>(
options: Options<GetBankProfileData, ThrowOnError>,
@@ -444,9 +594,11 @@ export const updateBankDisposition = <ThrowOnError extends boolean = false>(
});
/**
* Add/merge memory bank background
* Add/merge memory bank background (deprecated)
*
* Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
* Deprecated: Use PUT /mission instead. This endpoint now updates the mission field.
*
* @deprecated
*/
export const addBankBackground = <ThrowOnError extends boolean = false>(
options: Options<AddBankBackgroundData, ThrowOnError>,
@@ -478,10 +630,31 @@ export const deleteBank = <ThrowOnError extends boolean = false>(
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}", ...options });
/**
* Partial update memory bank
*
* Partially update an agent's profile. Only provided fields will be updated.
*/
export const updateBank = <ThrowOnError extends boolean = false>(
options: Options<UpdateBankData, ThrowOnError>,
) =>
(options.client ?? client).patch<
UpdateBankResponses,
UpdateBankErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Create or update memory bank
*
* Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
* Create a new agent or update existing agent with disposition and mission. Auto-fills missing fields with defaults.
*/
export const createOrUpdateBank = <ThrowOnError extends boolean = false>(
options: Options<CreateOrUpdateBankData, ThrowOnError>,
@@ -7,7 +7,7 @@ export type ClientOptions = {
/**
* AddBackgroundRequest
*
* Request model for adding/merging background information.
* Request model for adding/merging background information. Deprecated: use SetMissionRequest instead.
*/
export type AddBackgroundRequest = {
/**
@@ -19,21 +19,43 @@ export type AddBackgroundRequest = {
/**
* Update Disposition
*
* If true, infer disposition traits from the merged background (default: true)
* Deprecated - disposition is no longer auto-inferred from mission
*/
update_disposition?: boolean;
};
/**
* AsyncOperationSubmitResponse
*
* Response model for submitting an async operation.
*/
export type AsyncOperationSubmitResponse = {
/**
* Operation Id
*/
operation_id: string;
/**
* Status
*/
status: string;
};
/**
* BackgroundResponse
*
* Response model for background update.
* Response model for background update. Deprecated: use MissionResponse instead.
*/
export type BackgroundResponse = {
/**
* Background
* Mission
*/
background: string;
mission: string;
/**
* Background
*
* Deprecated: same as mission
*/
background?: string | null;
disposition?: DispositionTraits | null;
};
@@ -53,9 +75,9 @@ export type BankListItem = {
name?: string | null;
disposition: DispositionTraits;
/**
* Background
* Mission
*/
background?: string | null;
mission?: string | null;
/**
* Created At
*/
@@ -94,9 +116,17 @@ export type BankProfileResponse = {
name: string;
disposition: DispositionTraits;
/**
* Background
* Mission
*
* The agent's mission - who they are and what they're trying to accomplish
*/
background: string;
mission: string;
/**
* Background
*
* Deprecated: use mission instead
*/
background?: string | null;
};
/**
@@ -267,12 +297,72 @@ export type CreateBankRequest = {
*/
name?: string | null;
disposition?: DispositionTraits | null;
/**
* Mission
*
* The agent's mission
*/
mission?: string | null;
/**
* Background
*
* Deprecated: use mission instead
*/
background?: string | null;
};
/**
* CreateMentalModelRequest
*
* Request model for creating a pinned mental model.
*/
export type CreateMentalModelRequest = {
/**
* Name
*
* Human-readable name for the mental model
*/
name: string;
/**
* Description
*
* One-liner description for quick scanning
*/
description: string;
/**
* Tags
*
* Tags for scoped visibility
*/
tags?: Array<string>;
};
/**
* CreatedMentalModel
*
* A mental model created during reflection.
*/
export type CreatedMentalModel = {
/**
* Id
*
* Mental model ID
*/
id: string;
/**
* Name
*
* Human-readable name
*/
name: string;
/**
* Description
*
* What this model tracks
*/
description: string;
};
/**
* DeleteDocumentResponse
*
@@ -740,6 +830,98 @@ export type MemoryItem = {
tags?: Array<string> | null;
};
/**
* MentalModelListResponse
*
* Response model for listing mental models.
*/
export type MentalModelListResponse = {
/**
* Items
*/
items: Array<MentalModelResponse>;
};
/**
* MentalModelObservationResponse
*
* An observation within a mental model with its supporting memories.
*/
export type MentalModelObservationResponse = {
/**
* Title
*
* Observation header (empty for intro)
*/
title: string;
/**
* Text
*
* Observation content
*/
text: string;
/**
* Based On
*
* Memory IDs supporting this observation
*/
based_on?: Array<string>;
};
/**
* MentalModelResponse
*
* Response model for a mental model.
*/
export type MentalModelResponse = {
/**
* Id
*/
id: string;
/**
* Bank Id
*/
bank_id: string;
/**
* Subtype
*/
subtype: string;
/**
* Name
*/
name: string;
/**
* Description
*/
description: string;
/**
* Observations
*
* Structured observations with per-observation fact attribution
*/
observations?: Array<MentalModelObservationResponse>;
/**
* Entity Id
*/
entity_id?: string | null;
/**
* Links
*/
links?: Array<string>;
/**
* Tags
*/
tags?: Array<string>;
/**
* Last Updated
*/
last_updated?: string | null;
/**
* Created At
*/
created_at: string;
};
/**
* OperationResponse
*
@@ -761,7 +943,7 @@ export type OperationResponse = {
/**
* Document Id
*/
document_id: string | null;
document_id?: string | null;
/**
* Created At
*/
@@ -776,6 +958,42 @@ export type OperationResponse = {
error_message: string | null;
};
/**
* OperationStatusResponse
*
* Response model for getting a single operation status.
*/
export type OperationStatusResponse = {
/**
* Operation Id
*/
operation_id: string;
/**
* Status
*/
status: "pending" | "completed" | "failed" | "not_found";
/**
* Operation Type
*/
operation_type?: string | null;
/**
* Created At
*/
created_at?: string | null;
/**
* Updated At
*/
updated_at?: string | null;
/**
* Completed At
*/
completed_at?: string | null;
/**
* Error Message
*/
error_message?: string | null;
};
/**
* OperationsListResponse
*
@@ -786,6 +1004,10 @@ export type OperationsListResponse = {
* Bank Id
*/
bank_id: string;
/**
* Total
*/
total: number;
/**
* Operations
*/
@@ -805,7 +1027,7 @@ export type RecallRequest = {
/**
* Types
*
* List of fact types to recall (defaults to all if not specified)
* List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall).
*/
types?: Array<string> | null;
budget?: Budget;
@@ -933,6 +1155,26 @@ export type RecallResult = {
tags?: Array<string> | null;
};
/**
* ReflectBasedOn
*
* Evidence the response is based on: memories and mental models.
*/
export type ReflectBasedOn = {
/**
* Memories
*
* Memory facts used to generate the response
*/
memories?: Array<ReflectFact>;
/**
* Mental Models
*
* Mental models accessed during reflection
*/
mental_models?: Array<ReflectMentalModel>;
};
/**
* ReflectFact
*
@@ -975,6 +1217,74 @@ export type ReflectIncludeOptions = {
* Include facts that the answer is based on. Set to {} to enable, null to disable (default: disabled).
*/
facts?: FactsIncludeOptions | null;
/**
* Include tool calls trace. Set to {} for full trace (input+output), {output: false} for inputs only.
*/
tool_calls?: ToolCallsIncludeOptions | null;
};
/**
* ReflectLLMCall
*
* An LLM call made during reflect agent execution.
*/
export type ReflectLlmCall = {
/**
* Scope
*
* Call scope: agent_1, agent_2, final, etc.
*/
scope: string;
/**
* Duration Ms
*
* Execution time in milliseconds
*/
duration_ms: number;
};
/**
* ReflectMentalModel
*
* A mental model accessed during reflect.
*/
export type ReflectMentalModel = {
/**
* Id
*
* Mental model ID
*/
id: string;
/**
* Name
*
* Mental model name
*/
name: string;
/**
* Type
*
* Mental model type: entity, concept, event
*/
type: string;
/**
* Subtype
*
* Mental model subtype: structural, emergent, learned
*/
subtype: string;
/**
* Description
*
* Brief description
*/
description: string;
/**
* Summary
*
* Full summary (when looked up in detail)
*/
summary?: string | null;
};
/**
@@ -990,6 +1300,10 @@ export type ReflectRequest = {
budget?: Budget;
/**
* Context
*
* DEPRECATED: Additional context is now concatenated with the query. Pass context directly in the query field instead. If provided, it will be appended to the query for backward compatibility.
*
* @deprecated
*/
context?: string | null;
/**
@@ -1035,9 +1349,9 @@ export type ReflectResponse = {
*/
text: string;
/**
* Based On
* Evidence used to generate the response. Only present when include.facts is set.
*/
based_on?: Array<ReflectFact>;
based_on?: ReflectBasedOn | null;
/**
* Structured Output
*
@@ -1050,6 +1364,98 @@ export type ReflectResponse = {
* Token usage metrics for LLM calls during reflection.
*/
usage?: TokenUsage | null;
/**
* Execution trace of tool and LLM calls. Only present when include.tool_calls is set.
*/
trace?: ReflectTrace | null;
/**
* Mental Models Created
*
* Mental models created during this reflection (via the learn tool).
*/
mental_models_created?: Array<CreatedMentalModel>;
};
/**
* ReflectToolCall
*
* A tool call made during reflect agent execution.
*/
export type ReflectToolCall = {
/**
* Tool
*
* Tool name: lookup, recall, learn, expand
*/
tool: string;
/**
* Input
*
* Tool input parameters
*/
input: {
[key: string]: unknown;
};
/**
* Output
*
* Tool output (only included when include.tool_calls.output is true)
*/
output?: {
[key: string]: unknown;
} | null;
/**
* Duration Ms
*
* Execution time in milliseconds
*/
duration_ms: number;
/**
* Iteration
*
* Iteration number (1-based) when this tool was called
*/
iteration?: number;
};
/**
* ReflectTrace
*
* Execution trace of LLM and tool calls during reflection.
*/
export type ReflectTrace = {
/**
* Tool Calls
*
* Tool calls made during reflection
*/
tool_calls?: Array<ReflectToolCall>;
/**
* Llm Calls
*
* LLM calls made during reflection
*/
llm_calls?: Array<ReflectLlmCall>;
};
/**
* RefreshMentalModelsRequest
*
* Request model for refresh mental models endpoint.
*/
export type RefreshMentalModelsRequest = {
/**
* Tags
*
* Tags to apply to newly created mental models
*/
tags?: Array<string> | null;
/**
* Subtype
*
* Only refresh models of this subtype. If not specified, refreshes all subtypes.
*/
subtype?: "structural" | "emergent" | "pinned" | "learned" | null;
};
/**
@@ -1161,6 +1567,20 @@ export type TokenUsage = {
total_tokens?: number;
};
/**
* ToolCallsIncludeOptions
*
* Options for including tool calls in reflect results.
*/
export type ToolCallsIncludeOptions = {
/**
* Output
*
* Include tool outputs in the trace. Set to false to only include inputs (smaller payload).
*/
output?: boolean;
};
/**
* UpdateDispositionRequest
*
@@ -1627,6 +2047,268 @@ export type RegenerateEntityObservationsResponses = {
export type RegenerateEntityObservationsResponse =
RegenerateEntityObservationsResponses[keyof RegenerateEntityObservationsResponses];
export type ListMentalModelsData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: {
/**
* Subtype
*
* Filter by subtype: structural, emergent, or pinned
*/
subtype?: string | null;
/**
* Tags
*
* Filter by tags (includes untagged models)
*/
tags?: Array<string> | null;
/**
* Tags Match
*
* How to match tags: 'any' (OR), 'all' (AND), or 'exact'
*/
tags_match?: "any" | "all" | "exact";
};
url: "/v1/default/banks/{bank_id}/mental-models";
};
export type ListMentalModelsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListMentalModelsError =
ListMentalModelsErrors[keyof ListMentalModelsErrors];
export type ListMentalModelsResponses = {
/**
* Successful Response
*/
200: MentalModelListResponse;
};
export type ListMentalModelsResponse =
ListMentalModelsResponses[keyof ListMentalModelsResponses];
export type CreateMentalModelData = {
body: CreateMentalModelRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models";
};
export type CreateMentalModelErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateMentalModelError =
CreateMentalModelErrors[keyof CreateMentalModelErrors];
export type CreateMentalModelResponses = {
/**
* Successful Response
*/
200: MentalModelResponse;
};
export type CreateMentalModelResponse =
CreateMentalModelResponses[keyof CreateMentalModelResponses];
export type DeleteMentalModelData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Model Id
*/
model_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}";
};
export type DeleteMentalModelErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteMentalModelError =
DeleteMentalModelErrors[keyof DeleteMentalModelErrors];
export type DeleteMentalModelResponses = {
/**
* Successful Response
*/
200: DeleteResponse;
};
export type DeleteMentalModelResponse =
DeleteMentalModelResponses[keyof DeleteMentalModelResponses];
export type GetMentalModelData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Model Id
*/
model_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}";
};
export type GetMentalModelErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetMentalModelError =
GetMentalModelErrors[keyof GetMentalModelErrors];
export type GetMentalModelResponses = {
/**
* Successful Response
*/
200: MentalModelResponse;
};
export type GetMentalModelResponse =
GetMentalModelResponses[keyof GetMentalModelResponses];
export type RefreshMentalModelsData = {
/**
* Body
*/
body?: RefreshMentalModelsRequest | null;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/refresh";
};
export type RefreshMentalModelsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RefreshMentalModelsError =
RefreshMentalModelsErrors[keyof RefreshMentalModelsErrors];
export type RefreshMentalModelsResponses = {
/**
* Successful Response
*/
200: AsyncOperationSubmitResponse;
};
export type RefreshMentalModelsResponse =
RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses];
export type GenerateMentalModelData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Model Id
*/
model_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate";
};
export type GenerateMentalModelErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GenerateMentalModelError =
GenerateMentalModelErrors[keyof GenerateMentalModelErrors];
export type GenerateMentalModelResponses = {
/**
* Successful Response
*/
200: AsyncOperationSubmitResponse;
};
export type GenerateMentalModelResponse =
GenerateMentalModelResponses[keyof GenerateMentalModelResponses];
export type ListDocumentsData = {
body?: never;
headers?: {
@@ -1931,6 +2613,48 @@ export type CancelOperationResponses = {
export type CancelOperationResponse2 =
CancelOperationResponses[keyof CancelOperationResponses];
export type GetOperationStatusData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Operation Id
*/
operation_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/operations/{operation_id}";
};
export type GetOperationStatusErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetOperationStatusError =
GetOperationStatusErrors[keyof GetOperationStatusErrors];
export type GetOperationStatusResponses = {
/**
* Successful Response
*/
200: OperationStatusResponse;
};
export type GetOperationStatusResponse =
GetOperationStatusResponses[keyof GetOperationStatusResponses];
export type GetBankProfileData = {
body?: never;
headers?: {
@@ -2081,6 +2805,42 @@ export type DeleteBankResponses = {
export type DeleteBankResponse = DeleteBankResponses[keyof DeleteBankResponses];
export type UpdateBankData = {
body: CreateBankRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}";
};
export type UpdateBankErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateBankError = UpdateBankErrors[keyof UpdateBankErrors];
export type UpdateBankResponses = {
/**
* Successful Response
*/
200: BankProfileResponse;
};
export type UpdateBankResponse = UpdateBankResponses[keyof UpdateBankResponses];
export type CreateOrUpdateBankData = {
body: CreateBankRequest;
headers?: {
+3
View File
@@ -30,6 +30,7 @@
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
@@ -38,6 +39,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
@@ -56,6 +58,7 @@
"react": "^19.2.0",
"react-chrono": "^2.9.1",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react18-json-view": "^0.2.9",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function POST(
request: Request,
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
) {
try {
const { bankId, modelId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
const response = await sdk.generateMentalModel({
client: lowLevelClient,
path: { bank_id: bankId, model_id: modelId },
});
if (response.error) {
console.error("API error generating mental model:", response.error);
return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error generating mental model:", error);
return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 });
}
}
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
) {
try {
const { bankId, modelId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
const response = await sdk.deleteMentalModel({
client: lowLevelClient,
path: { bank_id: bankId, model_id: modelId },
});
if (response.error) {
console.error("API error deleting mental model:", response.error);
return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error deleting mental model:", error);
return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 });
}
}
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
// Parse request body for optional subtype filter
let body: { subtype?: "structural" | "emergent"; tags?: string[] } | undefined;
try {
const text = await request.text();
if (text) {
body = JSON.parse(text);
}
} catch {
// Empty body is fine
}
const response = await sdk.refreshMentalModels({
client: lowLevelClient,
path: { bank_id: bankId },
body: body,
});
if (response.error) {
console.error("API error refreshing mental models:", response.error);
return NextResponse.json({ error: "Failed to refresh mental models" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error refreshing mental models:", error);
return NextResponse.json({ error: "Failed to refresh mental models" }, { status: 500 });
}
}
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const response = await sdk.listMentalModels({
client: lowLevelClient,
path: { bank_id: bankId },
});
if (response.error) {
console.error("API error listing mental models:", response.error);
return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error listing mental models:", error);
return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 });
}
}
export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const body = await request.json();
// Call the dataplane API directly since SDK may not have the new endpoint yet
const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error("API error creating mental model:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to create mental model" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error("Error creating mental model:", error);
return NextResponse.json({ error: "Failed to create mental model" }, { status: 500 });
}
}
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function GET(
request: Request,
{ params }: { params: Promise<{ bankId: string; operationId: string }> }
) {
try {
const { bankId, operationId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!operationId) {
return NextResponse.json({ error: "operation_id is required" }, { status: 400 });
}
const response = await sdk.getOperationStatus({
client: lowLevelClient,
path: { bank_id: bankId, operation_id: operationId },
});
if (response.error) {
console.error("API error getting operation status:", response.error);
return NextResponse.json({ error: "Failed to get operation status" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error getting operation status:", error);
return NextResponse.json({ error: "Failed to get operation status" }, { status: 500 });
}
}
@@ -1,6 +1,68 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function PUT(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
const body = await request.json();
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const response = await sdk.createOrUpdateBank({
client: lowLevelClient,
path: { bank_id: bankId },
body: {
name: body.name,
mission: body.mission,
disposition: body.disposition,
},
});
if (response.error) {
console.error("API error updating bank:", response.error);
return NextResponse.json({ error: "Failed to update bank" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error updating bank:", error);
return NextResponse.json({ error: "Failed to update bank" }, { status: 500 });
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
const body = await request.json();
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const response = await sdk.updateBank({
client: lowLevelClient,
path: { bank_id: bankId },
body: {
name: body.name,
mission: body.mission,
disposition: body.disposition,
},
});
if (response.error) {
console.error("API error patching bank:", response.error);
return NextResponse.json({ error: "Failed to update bank" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error patching bank:", error);
return NextResponse.json({ error: "Failed to update bank" }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string }> }
@@ -5,21 +5,35 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const bankId = body.bank_id || body.agent_id || "default";
const { query, context, budget, thinking_budget, include_facts, tags, tags_match } = body;
const {
query,
budget,
thinking_budget,
include_facts,
include_tool_calls,
tags,
tags_match,
max_tokens,
} = body;
const requestBody: any = {
query,
budget: budget || (thinking_budget ? "mid" : "low"),
context: context || undefined,
tags,
tags_match,
max_tokens: max_tokens || undefined,
};
// Add include options if specified
const includeOptions: any = {};
if (include_facts) {
requestBody.include = {
facts: {},
};
includeOptions.facts = {};
}
if (include_tool_calls) {
includeOptions.tool_calls = {};
}
if (Object.keys(includeOptions).length > 0) {
requestBody.include = includeOptions;
}
const response = await sdk.reflect({
@@ -9,9 +9,10 @@ import { EntitiesView } from "@/components/entities-view";
import { ThinkView } from "@/components/think-view";
import { SearchDebugView } from "@/components/search-debug-view";
import { BankProfileView } from "@/components/bank-profile-view";
import { MentalModelsView } from "@/components/mental-models-view";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
type DataSubTab = "world" | "experience" | "opinion";
type DataSubTab = "world" | "experience" | "models";
export default function BankPage() {
const params = useParams();
@@ -54,7 +55,7 @@ export default function BankPage() {
{/* Recall Tab */}
{view === "recall" && (
<div>
<h1 className="text-3xl font-bold mb-2 text-foreground">Recall Analyzer</h1>
<h1 className="text-3xl font-bold mb-2 text-foreground">Recall</h1>
<p className="text-muted-foreground mb-6">
Analyze memory recall with detailed trace information and retrieval methods.
</p>
@@ -67,7 +68,8 @@ export default function BankPage() {
<div>
<h1 className="text-3xl font-bold mb-2 text-foreground">Reflect</h1>
<p className="text-muted-foreground mb-6">
Ask questions and get AI-powered answers based on stored memories.
Query the memory bank and generate a response with optional disposition-aware
reasoning.
</p>
<ThinkView />
</div>
@@ -110,15 +112,15 @@ export default function BankPage() {
)}
</button>
<button
onClick={() => handleDataSubTabChange("opinion")}
onClick={() => handleDataSubTabChange("models")}
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
subTab === "opinion"
subTab === "models"
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Opinions
{subTab === "opinion" && (
Mental Models
{subTab === "models" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
@@ -128,7 +130,7 @@ export default function BankPage() {
<div>
{subTab === "world" && <DataView key="world" factType="world" />}
{subTab === "experience" && <DataView key="experience" factType="experience" />}
{subTab === "opinion" && <DataView key="opinion" factType="opinion" />}
{subTab === "models" && <MentalModelsView key="models" />}
</div>
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
@@ -29,7 +29,6 @@ import {
RefreshCw,
Save,
Brain,
FileText,
Clock,
AlertCircle,
CheckCircle,
@@ -38,6 +37,7 @@ import {
FolderOpen,
Activity,
Trash2,
Target,
} from "lucide-react";
interface DispositionTraits {
@@ -50,7 +50,7 @@ interface BankProfile {
bank_id: string;
name: string;
disposition: DispositionTraits;
background: string;
mission: string;
}
interface BankStats {
@@ -75,8 +75,6 @@ interface BankStats {
interface Operation {
id: string;
task_type: string;
items_count: number;
document_id?: string;
created_at: string;
status: string;
error_message?: string;
@@ -175,39 +173,72 @@ export function BankProfileView() {
const [profile, setProfile] = useState<BankProfile | null>(null);
const [stats, setStats] = useState<BankStats | null>(null);
const [operations, setOperations] = useState<Operation[]>([]);
const [totalOperations, setTotalOperations] = useState(0);
const [mentalModelsCount, setMentalModelsCount] = useState(0);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
// Ref to track editMode for polling (avoids stale closure)
const editModeRef = useRef(editMode);
useEffect(() => {
editModeRef.current = editMode;
}, [editMode]);
// Delete state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Edit state
const [editBackground, setEditBackground] = useState("");
const [editMission, setEditMission] = useState("");
const [editDisposition, setEditDisposition] = useState<DispositionTraits>({
skepticism: 3,
literalism: 3,
empathy: 3,
});
const loadData = async () => {
const loadData = async (isPolling = false) => {
if (!currentBank) return;
// Don't overwrite form state during polling when in edit mode
// Use ref to get current value (avoids stale closure in setInterval)
if (isPolling && editModeRef.current) {
// Only refresh stats and operations during edit mode
try {
const [statsData, opsData, modelsData] = await Promise.all([
client.getBankStats(currentBank),
client.listOperations(currentBank),
client.listMentalModels(currentBank),
]);
setStats(statsData as BankStats);
setOperations((opsData as any)?.operations || []);
setTotalOperations((opsData as any)?.total || 0);
setMentalModelsCount(modelsData.items?.length || 0);
} catch (error) {
console.error("Error refreshing stats:", error);
}
return;
}
setLoading(true);
try {
const [profileData, statsData, opsData] = await Promise.all([
const [profileData, statsData, opsData, modelsData] = await Promise.all([
client.getBankProfile(currentBank),
client.getBankStats(currentBank),
client.listOperations(currentBank),
client.listMentalModels(currentBank),
]);
setProfile(profileData);
setStats(statsData as BankStats);
setOperations((opsData as any)?.operations || []);
setTotalOperations((opsData as any)?.total || 0);
setMentalModelsCount(modelsData.items?.length || 0);
// Initialize edit state
setEditBackground(profileData.background);
setEditDisposition(profileData.disposition);
// Only initialize edit state when not in edit mode
if (!editModeRef.current) {
setEditMission(profileData.mission || "");
setEditDisposition(profileData.disposition);
}
} catch (error) {
console.error("Error loading bank profile:", error);
alert("Error loading bank profile: " + (error as Error).message);
@@ -222,7 +253,7 @@ export function BankProfileView() {
setSaving(true);
try {
await client.updateBankProfile(currentBank, {
background: editBackground,
mission: editMission,
disposition: editDisposition,
});
await loadData();
@@ -237,7 +268,7 @@ export function BankProfileView() {
const handleCancel = () => {
if (profile) {
setEditBackground(profile.background);
setEditMission(profile.mission || "");
setEditDisposition(profile.disposition);
}
setEditMode(false);
@@ -264,8 +295,8 @@ export function BankProfileView() {
useEffect(() => {
if (currentBank) {
loadData();
// Refresh operations every 5 seconds
const interval = setInterval(loadData, 5000);
// Refresh stats/operations every 5 seconds (isPolling=true to avoid overwriting form)
const interval = setInterval(() => loadData(true), 5000);
return () => clearInterval(interval);
}
}, [currentBank]);
@@ -324,7 +355,7 @@ export function BankProfileView() {
</>
) : (
<>
<Button onClick={loadData} variant="secondary" size="sm">
<Button onClick={() => loadData()} variant="secondary" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
@@ -426,13 +457,11 @@ export function BankProfileView() {
{stats.nodes_by_fact_type?.experience || 0}
</p>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-amber-600 dark:text-amber-400 font-semibold uppercase tracking-wide">
Opinions
</p>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{stats.nodes_by_fact_type?.opinion || 0}
<div className="bg-primary/10 border border-primary/20 rounded-xl p-4 text-center">
<p className="text-xs text-primary font-semibold uppercase tracking-wide">
Mental Models
</p>
<p className="text-2xl font-bold text-primary mt-1">{mentalModelsCount}</p>
</div>
</div>
)}
@@ -461,33 +490,35 @@ export function BankProfileView() {
</CardContent>
</Card>
{/* Background */}
<div className="space-y-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="w-5 h-5 text-primary" />
Background
</CardTitle>
<CardDescription>Context used when forming opinions via Reflect</CardDescription>
</CardHeader>
<CardContent>
{editMode ? (
<Textarea
value={editBackground}
onChange={(e) => setEditBackground(e.target.value)}
placeholder="Enter background information..."
rows={5}
className="resize-none"
/>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{profile?.background || "No background information provided."}
</p>
)}
</CardContent>
</Card>
</div>
{/* Mission */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg">
<Target className="w-5 h-5 text-primary" />
Mission
</CardTitle>
<CardDescription>
Who the agent is and what they&apos;re trying to accomplish. Used for mental models
and reflect.
</CardDescription>
</CardHeader>
<CardContent>
{editMode ? (
<Textarea
value={editMission}
onChange={(e) => setEditMission(e.target.value)}
placeholder="e.g., I am a PM for the engineering team. I help coordinate sprints and track project progress..."
rows={6}
className="resize-none"
/>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{profile?.mission ||
"No mission set. Set a mission to derive structural mental models and personalize reflect responses."}
</p>
)}
</CardContent>
</Card>
</div>
{/* Operations Section */}
@@ -499,7 +530,10 @@ export function BankProfileView() {
<Activity className="w-5 h-5 text-primary" />
Background Operations
</CardTitle>
<CardDescription>Async tasks processing memories</CardDescription>
<CardDescription>
{totalOperations} total operation{totalOperations !== 1 ? "s" : ""}
{operations.length < totalOperations ? ` (showing last ${operations.length})` : ""}
</CardDescription>
</div>
{stats && (stats.pending_operations > 0 || stats.failed_operations > 0) && (
<div className="flex gap-3">
@@ -531,23 +565,17 @@ export function BankProfileView() {
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-center">Items</TableHead>
<TableHead>Document</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{operations.slice(0, 10).map((op) => (
{operations.map((op) => (
<TableRow key={op.id} className={op.status === "failed" ? "bg-red-500/5" : ""}>
<TableCell className="font-mono text-xs text-muted-foreground">
{op.id.substring(0, 8)}
</TableCell>
<TableCell className="font-medium">{op.task_type}</TableCell>
<TableCell className="text-center">{op.items_count}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{op.document_id ? op.document_id.substring(0, 12) + "..." : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(op.created_at).toLocaleString()}
</TableCell>
@@ -33,7 +33,7 @@ import { Switch } from "@/components/ui/switch";
import { MemoryDetailPanel } from "./memory-detail-panel";
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
type FactType = "world" | "experience" | "opinion";
type FactType = "world" | "experience";
type ViewMode = "graph" | "table" | "timeline";
interface DataViewProps {
@@ -23,12 +23,7 @@ interface Entity {
metadata?: Record<string, any>;
}
interface EntityDetail extends Entity {
observations: Array<{
text: string;
mentioned_at?: string;
}>;
}
type EntityDetail = Entity;
const ITEMS_PER_PAGE = 50;
@@ -38,7 +33,6 @@ export function EntitiesView() {
const [loading, setLoading] = useState(false);
const [selectedEntity, setSelectedEntity] = useState<EntityDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [regenerating, setRegenerating] = useState(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -83,22 +77,6 @@ export function EntitiesView() {
}
};
const regenerateObservations = async () => {
if (!currentBank || !selectedEntity) return;
setRegenerating(true);
try {
await client.regenerateEntityObservations(selectedEntity.id, currentBank);
// Reload entity detail to show new observations
await loadEntityDetail(selectedEntity.id);
} catch (error) {
console.error("Error regenerating observations:", error);
alert("Error regenerating observations: " + (error as Error).message);
} finally {
setRegenerating(false);
}
};
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
@@ -284,45 +262,6 @@ export function EntitiesView() {
{selectedEntity.id}
</code>
</div>
{/* Observations */}
<div>
<div className="flex justify-between items-center mb-3">
<div className="text-xs font-bold text-muted-foreground uppercase">
Observations
</div>
<Button
onClick={regenerateObservations}
disabled={regenerating}
variant="outline"
size="sm"
>
{regenerating ? "Regenerating..." : "Regenerate"}
</Button>
</div>
{loadingDetail ? (
<div className="text-muted-foreground text-sm">Loading observations...</div>
) : selectedEntity.observations && selectedEntity.observations.length > 0 ? (
<ul className="space-y-2">
{selectedEntity.observations.map((obs, idx) => (
<li key={idx} className="p-3 bg-muted/50 rounded-lg">
<div className="text-sm text-card-foreground">{obs.text}</div>
{obs.mentioned_at && (
<div className="text-xs text-muted-foreground mt-2">
{formatDate(obs.mentioned_at)}
</div>
)}
</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-sm p-4 bg-muted/50 rounded-lg">
No observations yet. Click &quot;Regenerate&quot; to generate observations from
facts.
</div>
)}
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,7 @@ import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel";
type FactType = "world" | "experience" | "opinion";
type FactType = "world" | "experience";
type Budget = "low" | "mid" | "high";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "results" | "trace" | "json";
@@ -192,7 +192,7 @@ export function SearchDebugView() {
<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", "opinion"] as FactType[]).map((ft) => (
{(["world", "experience"] as FactType[]).map((ft) => (
<label key={ft} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={factTypes.includes(ft)}
@@ -5,7 +5,6 @@ import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -15,19 +14,21 @@ import {
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, Info, Tag } from "lucide-react";
import { Sparkles, Info, Tag, Clock, Database, Brain } from "lucide-react";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "answer" | "trace" | "json";
export function ThinkView() {
const { currentBank } = useBank();
const [query, setQuery] = useState("");
const [context, setContext] = useState("");
const [budget, setBudget] = useState<"low" | "mid" | "high">("mid");
const [maxTokens, setMaxTokens] = useState<number>(4096);
const [includeFacts, setIncludeFacts] = useState(true);
const [showRawJson, setShowRawJson] = useState(false);
const [includeToolCalls, setIncludeToolCalls] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("answer");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState("");
@@ -37,7 +38,7 @@ export function ThinkView() {
if (!currentBank || !query) return;
setLoading(true);
setShowRawJson(false);
setViewMode("answer");
try {
// Parse tags from comma-separated string
const parsedTags = tags
@@ -49,8 +50,9 @@ export function ThinkView() {
bank_id: currentBank,
query,
budget,
context: context || undefined,
max_tokens: maxTokens,
include_facts: includeFacts,
include_tool_calls: includeToolCalls,
...(parsedTags.length > 0 && { tags: parsedTags, tags_match: tagsMatch }),
});
setResult(data);
@@ -62,25 +64,47 @@ export function ThinkView() {
}
};
if (!currentBank) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Database className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-xl font-semibold mb-2">No Bank Selected</h3>
<p className="text-muted-foreground">Select a memory bank to start reflecting.</p>
</CardContent>
</Card>
);
}
return (
<div className="max-w-6xl">
<div className="space-y-6">
{/* Query Input */}
<Card>
<CardContent className="p-5 space-y-4">
<div className="flex gap-4 items-end flex-wrap">
<div className="flex-1 min-w-[300px]">
<label className="font-bold block mb-2 text-card-foreground">Question:</label>
<CardContent className="pt-6">
<div className="flex gap-3">
<div className="flex-1 relative">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter your question..."
placeholder="What would you like to reflect on?"
className="pl-10 h-12 text-lg"
onKeyDown={(e) => e.key === "Enter" && runReflect()}
/>
</div>
<div>
<label className="font-bold block mb-2 text-card-foreground">Budget:</label>
<Button onClick={runReflect} disabled={loading || !query} className="h-12 px-8">
{loading ? "Reflecting..." : "Reflect"}
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
{/* Budget */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Budget:</span>
<Select value={budget} onValueChange={(value: any) => setBudget(value)}>
<SelectTrigger className="w-24">
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -90,30 +114,40 @@ export function ThinkView() {
</SelectContent>
</Select>
</div>
{/* Max Tokens */}
<div className="flex items-center gap-2">
<Checkbox
id="include-facts"
checked={includeFacts}
onCheckedChange={(checked) => setIncludeFacts(checked as boolean)}
<span className="text-sm text-muted-foreground">Tokens:</span>
<Input
type="number"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 4096)}
className="w-24 h-8"
/>
<label htmlFor="include-facts" className="text-sm cursor-pointer">
Include Facts
</div>
<div className="h-6 w-px bg-border" />
{/* Include options */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={includeFacts}
onCheckedChange={(c) => setIncludeFacts(c as boolean)}
/>
<span className="text-sm">Include Facts</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={includeToolCalls}
onCheckedChange={(c) => setIncludeToolCalls(c as boolean)}
/>
<span className="text-sm">Include Tools</span>
</label>
</div>
<Button onClick={runReflect} disabled={loading || !query}>
<Sparkles className="w-4 h-4 mr-2" />
Reflect
</Button>
</div>
<div>
<label className="font-bold block mb-2 text-card-foreground">Context (optional):</label>
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder="Additional context for the LLM (not used in search)..."
rows={3}
/>
</div>
{/* Tags Filter */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<Tag className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 max-w-md">
@@ -140,206 +174,518 @@ export function ThinkView() {
</CardContent>
</Card>
{/* Loading State */}
{loading && (
<Card className="mt-6">
<CardContent className="text-center py-10">
<Sparkles className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
<div className="text-lg text-muted-foreground">Reflecting...</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4" />
<p className="text-muted-foreground">Reflecting on memories...</p>
</CardContent>
</Card>
)}
{result && !loading && (
<div className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Answer</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 bg-muted rounded-lg border-l-4 border-primary text-base leading-relaxed whitespace-pre-wrap">
{result.text}
{/* Results */}
{!loading && result && (
<div className="space-y-4">
{/* Summary Stats & Tabs */}
<div className="flex items-center gap-6 text-sm">
{result.usage && (
<>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Input tokens:</span>
<span className="font-semibold">
{result.usage.input_tokens?.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Output tokens:</span>
<span className="font-semibold">
{result.usage.output_tokens?.toLocaleString()}
</span>
</div>
</>
)}
{result.trace?.tool_calls && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tool calls:</span>
<span className="font-semibold">{result.trace.tool_calls.length}</span>
<span className="text-muted-foreground">
(
{result.trace.tool_calls.reduce(
(sum: number, tc: any) => sum + tc.duration_ms,
0
)}
ms)
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Details</CardTitle>
<CardDescription>View facts and raw response</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant={!showRawJson ? "default" : "outline"}
size="sm"
onClick={() => setShowRawJson(false)}
>
Based On
</Button>
<Button
variant={showRawJson ? "default" : "outline"}
size="sm"
onClick={() => setShowRawJson(true)}
>
Raw JSON
</Button>
</div>
)}
{result.trace?.llm_calls && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">LLM calls:</span>
<span className="font-semibold">{result.trace.llm_calls.length}</span>
<span className="text-muted-foreground">
(
{result.trace.llm_calls.reduce((sum: number, lc: any) => sum + lc.duration_ms, 0)}
ms)
</span>
</div>
</CardHeader>
<CardContent>
{!showRawJson ? (
includeFacts && result.based_on && result.based_on.length > 0 ? (
(() => {
// Group facts by type
const worldFacts = result.based_on.filter((f: any) => f.type === "world");
const experienceFacts = result.based_on.filter(
(f: any) => f.type === "experience"
);
const opinionFacts = result.based_on.filter((f: any) => f.type === "opinion");
)}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">World Facts</CardTitle>
<CardDescription className="text-xs">General Knowledge</CardDescription>
</CardHeader>
<CardContent>
{worldFacts.length > 0 ? (
<ul className="text-sm space-y-2">
{worldFacts.map((fact: any, i: number) => (
<li key={i} className="p-2 bg-muted rounded">
{fact.text}
{fact.context && (
<div className="text-xs text-muted-foreground mt-1">
{fact.context}
</div>
)}
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">None</p>
)}
</CardContent>
</Card>
<div className="flex-1" />
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Experience</CardTitle>
<CardDescription className="text-xs">
Conversations & Events
</CardDescription>
</CardHeader>
<CardContent>
{experienceFacts.length > 0 ? (
<ul className="text-sm space-y-2">
{experienceFacts.map((fact: any, i: number) => (
<li key={i} className="p-2 bg-muted rounded">
{fact.text}
{fact.context && (
<div className="text-xs text-muted-foreground mt-1">
{fact.context}
</div>
)}
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">None</p>
)}
</CardContent>
</Card>
{/* View Mode Tabs */}
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{(["answer", "trace", "json"] as ViewMode[]).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
viewMode === mode
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{mode === "answer" ? "Answer" : mode === "trace" ? "Trace" : "JSON"}
</button>
))}
</div>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Opinions</CardTitle>
<CardDescription className="text-xs">
Beliefs & Preferences
</CardDescription>
</CardHeader>
<CardContent>
{opinionFacts.length > 0 ? (
<ul className="text-sm space-y-2">
{opinionFacts.map((fact: any, i: number) => (
<li key={i} className="p-2 bg-muted rounded">
{fact.text}
{fact.context && (
<div className="text-xs text-muted-foreground mt-1">
{fact.context}
</div>
)}
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">None</p>
)}
</CardContent>
</Card>
</div>
);
})()
) : includeFacts ? (
<div className="flex items-start gap-3 p-4 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg">
<Info className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-semibold text-amber-900 dark:text-amber-100">
No facts found
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
No memories were found or used to generate this answer.
</p>
{/* Answer View */}
{viewMode === "answer" && (
<div className="space-y-6">
{/* Main Answer */}
<Card>
<CardHeader>
<CardTitle>Answer</CardTitle>
</CardHeader>
<CardContent>
<div className="text-base leading-relaxed whitespace-pre-wrap">{result.text}</div>
</CardContent>
</Card>
{/* New Opinions Formed */}
{result.new_opinions && result.new_opinions.length > 0 && (
<Card className="border-green-200 dark:border-green-800">
<CardHeader className="bg-green-50 dark:bg-green-950">
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
New Opinions Formed
</CardTitle>
<CardDescription>New beliefs generated from this interaction</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<div className="space-y-3">
{result.new_opinions.map((opinion: any, i: number) => (
<div key={i} className="p-3 bg-muted rounded-lg border border-border">
<div className="font-semibold text-foreground">{opinion.text}</div>
<div className="text-sm text-muted-foreground mt-1">
Confidence: {opinion.confidence?.toFixed(2)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg">
<Info className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-semibold text-amber-900 dark:text-amber-100">
Facts not included
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Enable "Include Facts" above to see which memories were used to generate
this answer.
</p>
</div>
</div>
)
) : (
<div className="bg-muted p-4 rounded border border-border overflow-auto max-h-[600px]">
<JsonView src={result} collapsed={1} theme="default" />
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
</div>
)}
{result.new_opinions && result.new_opinions.length > 0 && (
<Card className="border-green-200 dark:border-green-800">
<CardHeader className="bg-green-50 dark:bg-green-950">
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
New Opinions Formed
</CardTitle>
<CardDescription>New beliefs generated from this interaction</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<div className="space-y-3">
{result.new_opinions.map((opinion: any, i: number) => (
<div key={i} className="p-3 bg-muted rounded-lg border border-border">
<div className="font-semibold text-foreground">{opinion.text}</div>
<div className="text-sm text-muted-foreground mt-1">
Confidence: {opinion.confidence?.toFixed(2)}
</div>
{/* Trace View - Split Layout */}
{viewMode === "trace" && (
<div className="space-y-4">
{/* Mental Models Created */}
{result.mental_models_created && result.mental_models_created.length > 0 && (
<Card className="border-emerald-200 dark:border-emerald-800">
<CardHeader className="bg-emerald-50 dark:bg-emerald-950 py-3">
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="w-4 h-4 text-emerald-600" />
Mental Models Created ({result.mental_models_created.length})
</CardTitle>
<CardDescription className="text-xs">
New mental models learned during this reflection
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="space-y-2">
{result.mental_models_created.map((model: any, i: number) => (
<div
key={i}
className="p-3 bg-emerald-50 dark:bg-emerald-950/50 rounded-lg border border-emerald-200 dark:border-emerald-800"
>
<div className="font-medium text-sm text-emerald-900 dark:text-emerald-100">
{model.name}
</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-1">
{model.description}
</div>
<div className="text-[10px] text-muted-foreground mt-2 font-mono">
ID: {model.id}
</div>
</div>
))}
</div>
))}
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Execution Trace (LLM + Tool Calls) */}
<Card className="h-fit">
<CardHeader className="pb-3">
<CardTitle className="text-base">Execution Trace</CardTitle>
<CardDescription className="text-xs">
{result.iterations || 0} iteration
{(result.iterations || 0) !== 1 ? "s" : ""} {" "}
{(result.trace?.llm_calls?.reduce(
(sum: number, lc: any) => sum + lc.duration_ms,
0
) || 0) +
(result.trace?.tool_calls?.reduce(
(sum: number, tc: any) => sum + tc.duration_ms,
0
) || 0)}
ms total
</CardDescription>
</CardHeader>
<CardContent>
{!includeToolCalls ? (
<div className="flex items-start gap-3 p-3 bg-muted border border-border rounded-lg">
<Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm text-foreground">Not included</p>
<p className="text-xs text-muted-foreground mt-0.5">
Enable "Include Tool Calls" to see trace.
</p>
</div>
</div>
) : (result.trace?.llm_calls && result.trace.llm_calls.length > 0) ||
(result.trace?.tool_calls && result.trace.tool_calls.length > 0) ? (
<div className="max-h-[500px] overflow-y-auto">
{/* Build timeline: LLM -> Tools -> LLM -> Tools */}
{(() => {
const llmCalls = result.trace?.llm_calls || [];
const toolCalls = result.trace?.tool_calls || [];
// Build interleaved timeline
const timeline: Array<{
type: "llm" | "tools";
llm?: any;
tools?: any[];
iteration: number;
isFinal?: boolean;
}> = [];
llmCalls.forEach((lc: any, idx: number) => {
const isFinal = lc.scope.includes("final");
const iterNum = isFinal ? llmCalls.length : idx + 1;
// Add LLM call
timeline.push({
type: "llm",
llm: lc,
iteration: iterNum,
isFinal,
});
// Add tools for this iteration (using iteration field from tool trace)
const iterTools = toolCalls.filter(
(tc: any) => tc.iteration === idx + 1
);
if (iterTools.length > 0) {
timeline.push({
type: "tools",
tools: iterTools,
iteration: idx + 1,
});
}
});
return timeline.map((item, idx) => (
<div key={idx} className="relative">
{/* Timeline connector */}
{idx < timeline.length - 1 && (
<div className="absolute left-3 top-6 bottom-0 w-0.5 bg-border" />
)}
{item.type === "llm" ? (
// LLM Call
<div className="flex items-start gap-3 pb-3">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${
item.isFinal
? "bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300"
: "bg-violet-100 dark:bg-violet-900 text-violet-700 dark:text-violet-300"
}`}
>
{item.isFinal ? "✓" : item.iteration}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{item.isFinal ? "Response generated" : "Agent decided"}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{item.llm.duration_ms}ms
</span>
</div>
<span className="text-xs text-muted-foreground">
{item.isFinal ? "Final answer" : "Called tools below"}
</span>
</div>
</div>
) : (
// Tool Calls
<div className="flex items-start gap-3 pb-3">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 flex-shrink-0">
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="text-xs text-muted-foreground">
Executing {item.tools?.length} tool
{item.tools?.length !== 1 ? "s" : ""}
</div>
{item.tools?.map((tc: any, tcIdx: number) => (
<div
key={tcIdx}
className="border border-border rounded-lg overflow-hidden"
>
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50">
<span className="font-medium text-sm text-foreground">
{tc.tool}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{tc.duration_ms}ms
</span>
</div>
<div className="p-2 space-y-2">
<div>
<p className="text-[10px] font-semibold text-muted-foreground mb-1">
Input:
</p>
<div className="bg-muted p-1.5 rounded text-xs overflow-auto max-h-32">
<JsonView
src={tc.input}
collapsed={1}
theme="default"
/>
</div>
</div>
{tc.output && (
<div>
<p className="text-[10px] font-semibold text-muted-foreground mb-1">
Output:
</p>
<div className="bg-muted p-1.5 rounded text-xs overflow-auto max-h-32">
<JsonView
src={tc.output}
collapsed={1}
theme="default"
/>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
));
})()}
</div>
) : (
<div className="flex items-start gap-3 p-3 bg-muted border border-border rounded-lg">
<Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm text-foreground">No operations</p>
<p className="text-xs text-muted-foreground mt-0.5">
No LLM or tool calls were made during this reflection.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Right: Based On Facts */}
<Card className="h-fit">
<CardHeader className="pb-3">
<CardTitle className="text-base">Based On</CardTitle>
<CardDescription className="text-xs">
{(result.based_on?.memories?.length || 0) +
(result.based_on?.mental_models?.length || 0)}{" "}
items used
</CardDescription>
</CardHeader>
<CardContent>
{!includeFacts ? (
<div className="flex items-start gap-3 p-3 bg-muted border border-border rounded-lg">
<Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm text-foreground">Not included</p>
<p className="text-xs text-muted-foreground mt-0.5">
Enable "Include Facts" to see memories.
</p>
</div>
</div>
) : (result.based_on?.memories && result.based_on.memories.length > 0) ||
(result.based_on?.mental_models &&
result.based_on.mental_models.length > 0) ? (
<div className="space-y-4 max-h-[500px] overflow-y-auto">
{(() => {
const memories = result.based_on?.memories || [];
const worldFacts = memories.filter((f: any) => f.type === "world");
const experienceFacts = memories.filter(
(f: any) => f.type === "experience"
);
const opinionFacts = memories.filter((f: any) => f.type === "opinion");
const mentalModels = result.based_on?.mental_models || [];
return (
<>
{/* Mental Models */}
{mentalModels.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-orange-600 dark:text-orange-400">
<div className="w-2 h-2 rounded-full bg-orange-500" />
Mental Models ({mentalModels.length})
</div>
<div className="space-y-1.5">
{mentalModels.map((model: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
<div className="font-medium">{model.name}</div>
{model.description && (
<div className="text-[10px] text-muted-foreground mt-1">
{model.description}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* World Facts */}
{worldFacts.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-blue-600 dark:text-blue-400">
<div className="w-2 h-2 rounded-full bg-blue-500" />
World ({worldFacts.length})
</div>
<div className="space-y-1.5">
{worldFacts.map((fact: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
{fact.text}
{fact.context && (
<div className="text-[10px] text-muted-foreground mt-1">
{fact.context}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Experience Facts */}
{experienceFacts.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-green-600 dark:text-green-400">
<div className="w-2 h-2 rounded-full bg-green-500" />
Experience ({experienceFacts.length})
</div>
<div className="space-y-1.5">
{experienceFacts.map((fact: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
{fact.text}
{fact.context && (
<div className="text-[10px] text-muted-foreground mt-1">
{fact.context}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Opinion Facts */}
{opinionFacts.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-purple-600 dark:text-purple-400">
<div className="w-2 h-2 rounded-full bg-purple-500" />
Opinions ({opinionFacts.length})
</div>
<div className="space-y-1.5">
{opinionFacts.map((fact: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
{fact.text}
{fact.context && (
<div className="text-[10px] text-muted-foreground mt-1">
{fact.context}
</div>
)}
</div>
))}
</div>
</div>
)}
</>
);
})()}
</div>
) : (
<div className="flex items-start gap-3 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg">
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm text-amber-900 dark:text-amber-100">
No facts found
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-0.5">
No memories were used to generate this answer.
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* JSON View */}
{viewMode === "json" && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Raw Response</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-muted p-4 rounded-lg overflow-auto max-h-[600px]">
<JsonView src={result} collapsed={2} theme="default" />
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Empty State */}
{!loading && !result && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Ready to Reflect</h3>
<p className="text-muted-foreground text-center max-w-md">
Enter a question above to query the memory bank and generate a disposition-aware
response.
</p>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,187 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+118 -3
View File
@@ -69,8 +69,9 @@ export class ControlPlaneClient {
query: string;
bank_id: string;
budget?: string;
context?: string;
max_tokens?: number;
include_facts?: boolean;
include_tool_calls?: boolean;
tags?: string[];
tags_match?: "any" | "all" | "any_strict" | "all_strict";
}) {
@@ -245,10 +246,124 @@ export class ControlPlaneClient {
literalism: number;
empathy: number;
};
background: string;
mission: string;
background?: string; // Deprecated, kept for backwards compatibility
}>(`/api/profile/${bankId}`);
}
/**
* Set bank mission
*/
async setBankMission(bankId: string, mission: string) {
return this.fetchApi(`/api/banks/${bankId}`, {
method: "PATCH",
body: JSON.stringify({ mission }),
});
}
/**
* List mental models for a bank
*/
async listMentalModels(bankId: string) {
return this.fetchApi<{
items: Array<{
id: string;
bank_id: string;
subtype: string;
name: string;
description: string;
observations?: Array<{ title: string; text: string; based_on: string[] }>;
entity_id: string | null;
links: string[];
tags?: string[];
last_updated: string | null;
created_at: string;
}>;
}>(`/api/banks/${bankId}/mental-models`);
}
/**
* Refresh mental models for a bank (async)
* @param subtype - Optional subtype to refresh. If not specified, refreshes all.
*/
async refreshMentalModels(
bankId: string,
subtype?: "structural" | "emergent" | "pinned" | "learned"
) {
return this.fetchApi<{ operation_id: string; message: string }>(
`/api/banks/${bankId}/mental-models/refresh`,
{
method: "POST",
body: JSON.stringify(subtype ? { subtype } : {}),
}
);
}
/**
* Delete a mental model
*/
async deleteMentalModel(bankId: string, modelId: string) {
return this.fetchApi(`/api/banks/${bankId}/mental-models/${modelId}`, {
method: "DELETE",
});
}
/**
* Get operation status
*/
async getOperationStatus(bankId: string, operationId: string) {
return this.fetchApi<{
operation_id: string;
status: "pending" | "completed" | "failed" | "not_found";
operation_type: string | null;
created_at: string | null;
updated_at: string | null;
completed_at: string | null;
error_message: string | null;
}>(`/api/banks/${bankId}/operations/${operationId}`);
}
/**
* Generate/refresh content for a specific mental model (async)
*/
async generateMentalModel(bankId: string, modelId: string) {
return this.fetchApi<{ operation_id: string; message: string }>(
`/api/banks/${bankId}/mental-models/${modelId}/generate`,
{
method: "POST",
}
);
}
/**
* Create a pinned mental model
*/
async createMentalModel(
bankId: string,
params: {
name: string;
description: string;
tags?: string[];
}
) {
return this.fetchApi<{
id: string;
bank_id: string;
subtype: string;
name: string;
description: string;
observations?: Array<{ title: string; text: string; based_on: string[] }>;
entity_id: string | null;
links: string[];
tags?: string[];
last_updated: string | null;
created_at: string;
}>(`/api/banks/${bankId}/mental-models`, {
method: "POST",
body: JSON.stringify(params),
});
}
/**
* Update bank profile
*/
@@ -261,7 +376,7 @@ export class ControlPlaneClient {
literalism: number;
empathy: number;
};
background?: string;
mission?: string;
}
) {
return this.fetchApi(`/api/profile/${bankId}`, {
@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography";
const config: Config = {
content: [
@@ -33,6 +34,7 @@ const config: Config = {
},
},
},
plugins: [typography],
};
export default config;
@@ -220,10 +220,10 @@ After installation, verify the connection:
```bash
# Store a test memory
hindsight memory retain team-acme-frontend "Test memory from setup"
hindsight memory retain team-acme-frontend "Alice works at Google as a software engineer"
# Recall it
hindsight memory recall team-acme-frontend "test"
hindsight memory recall team-acme-frontend "Alice"
```
### Switching Between Banks
+4 -4
View File
@@ -414,8 +414,8 @@ if [ "$MODE" = "local" ]; then
echo -e " The Hindsight skill is now available in ${BOLD}$APP_NAME${NC}."
echo ""
echo -e " ${DIM}Test the CLI:${NC}"
echo -e " ${CYAN}uvx hindsight-embed memory retain default \"Test memory\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed memory recall default \"test\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed memory retain default \"Alice works at Google as a software engineer\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed memory recall default \"Alice\"${NC}"
echo ""
echo -e " ${DIM}$APP_NAME will automatically use the skill when relevant.${NC}"
echo ""
@@ -505,8 +505,8 @@ EOF
echo -e " Memory bank: ${CYAN}$CLOUD_BANK_ID${NC}"
echo ""
echo -e " ${DIM}Test the CLI:${NC}"
echo -e " ${CYAN}hindsight memory retain $CLOUD_BANK_ID \"Test memory\"${NC}"
echo -e " ${CYAN}hindsight memory recall $CLOUD_BANK_ID \"test\"${NC}"
echo -e " ${CYAN}hindsight memory retain $CLOUD_BANK_ID \"Alice works at Google as a software engineer\"${NC}"
echo -e " ${CYAN}hindsight memory recall $CLOUD_BANK_ID \"Alice\"${NC}"
echo ""
echo -e " ${DIM}Share this bank ID with your team for shared memories.${NC}"
echo -e " ${DIM}$APP_NAME will automatically use the skill when relevant.${NC}"
File diff suppressed because it is too large Load Diff
@@ -90,7 +90,7 @@ async def test_create_bank_and_list_banks():
arguments={
"bank_id": bank_id,
"name": "Test Bank",
"background": "A bank for testing MCP integration",
"mission": "A bank for testing MCP integration",
},
)
print(f"Create bank result: {create_result}")
@@ -104,7 +104,7 @@ async def test_create_bank_and_list_banks():
# Check fields match BankProfileResponse schema
assert result_data.get("bank_id") == bank_id
assert result_data.get("name") == "Test Bank"
assert result_data.get("background") == "A bank for testing MCP integration"
assert result_data.get("mission") == "A bank for testing MCP integration"
assert "disposition" in result_data # DispositionTraits object
# Test 2: List banks and verify our bank is there
+154 -2
View File
@@ -13,7 +13,7 @@
},
"hindsight-clients/typescript": {
"name": "@vectorize-io/hindsight-client",
"version": "0.2.1",
"version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.0",
@@ -131,12 +131,13 @@
},
"hindsight-control-plane": {
"name": "@vectorize-io/hindsight-control-plane",
"version": "0.2.1",
"version": "0.3.0",
"license": "ISC",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
@@ -145,6 +146,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
@@ -163,6 +165,7 @@
"react": "^19.2.0",
"react-chrono": "^2.9.1",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react18-json-view": "^0.2.9",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
@@ -7014,6 +7017,35 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -7118,6 +7150,64 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@@ -8402,6 +8492,31 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -16094,6 +16209,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
@@ -24473,6 +24598,33 @@
"webpack": ">=4.41.1 || 5.x"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"@types/react": ">=18",
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",