Skip to content

Commit 0430588

Browse files
authored
fix: hindsight-embed profiles are not loaded correctly (#316)
* fix: hindsight-embed profiles are not loaded correctly * fix: hindsight-embed profiles are not loaded correctly
1 parent 2af0e08 commit 0430588

File tree

7 files changed

+498
-54
lines changed

7 files changed

+498
-54
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Tests for configuration validation.
3+
4+
Verifies that config validation catches invalid parameter combinations.
5+
"""
6+
7+
import os
8+
9+
import pytest
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def setup_test_env():
14+
"""Set up environment for each test, restoring original values after."""
15+
from hindsight_api.config import clear_config_cache
16+
17+
# Save original environment values
18+
env_vars_to_save = [
19+
"HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
20+
"HINDSIGHT_API_RETAIN_CHUNK_SIZE",
21+
"HINDSIGHT_API_LLM_PROVIDER",
22+
"HINDSIGHT_API_LLM_MODEL",
23+
]
24+
25+
# Save original values
26+
original_values = {}
27+
for key in env_vars_to_save:
28+
original_values[key] = os.environ.get(key)
29+
30+
clear_config_cache()
31+
32+
yield
33+
34+
# Restore original environment
35+
for key, original_value in original_values.items():
36+
if original_value is None:
37+
os.environ.pop(key, None)
38+
else:
39+
os.environ[key] = original_value
40+
41+
clear_config_cache()
42+
43+
44+
def test_retain_max_completion_tokens_must_be_greater_than_chunk_size():
45+
"""Test that RETAIN_MAX_COMPLETION_TOKENS > RETAIN_CHUNK_SIZE validation works."""
46+
from hindsight_api.config import HindsightConfig
47+
48+
# Set invalid config: max_completion_tokens <= chunk_size
49+
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "1000"
50+
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "2000"
51+
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
52+
53+
# Should raise ValueError with helpful message
54+
with pytest.raises(ValueError) as exc_info:
55+
HindsightConfig.from_env()
56+
57+
error_message = str(exc_info.value)
58+
59+
# Verify error message contains helpful information
60+
assert "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message
61+
assert "1000" in error_message
62+
assert "HINDSIGHT_API_RETAIN_CHUNK_SIZE" in error_message
63+
assert "2000" in error_message
64+
assert "must be greater than" in error_message
65+
assert "You have two options to fix this:" in error_message
66+
assert "Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message
67+
assert "Use a model that supports" in error_message
68+
69+
70+
def test_retain_max_completion_tokens_equal_to_chunk_size_fails():
71+
"""Test that RETAIN_MAX_COMPLETION_TOKENS == RETAIN_CHUNK_SIZE also fails."""
72+
from hindsight_api.config import HindsightConfig
73+
74+
# Set invalid config: max_completion_tokens == chunk_size
75+
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "3000"
76+
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000"
77+
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
78+
79+
# Should raise ValueError
80+
with pytest.raises(ValueError) as exc_info:
81+
HindsightConfig.from_env()
82+
83+
error_message = str(exc_info.value)
84+
assert "must be greater than" in error_message
85+
86+
87+
def test_valid_retain_config_succeeds():
88+
"""Test that valid config with max_completion_tokens > chunk_size works."""
89+
from hindsight_api.config import HindsightConfig
90+
91+
# Set valid config: max_completion_tokens > chunk_size
92+
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000"
93+
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000"
94+
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
95+
96+
# Should not raise
97+
config = HindsightConfig.from_env()
98+
assert config.retain_max_completion_tokens == 64000
99+
assert config.retain_chunk_size == 3000
100+
101+
102+
# Note: The BadRequestError wrapping is implemented in fact_extraction.py
103+
# but requires a complex integration test setup. The functionality is
104+
# straightforward: when a BadRequestError containing keywords like
105+
# "max_tokens", "max_completion_tokens", or "maximum context" is caught,
106+
# it's wrapped in a ValueError with helpful guidance.
107+
#
108+
# The config validation tests above ensure users get early feedback
109+
# about invalid configurations before runtime errors occur.

hindsight-api/tests/test_mental_models.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -738,12 +738,20 @@ async def test_refresh_with_tags_only_accesses_same_tagged_models(
738738
"Refreshed model should access memories/models with matching tags (user:alice)"
739739

740740
# MUST NOT include Bob's content (security violation)
741-
assert "bob" not in refreshed_content and "python" not in refreshed_content and "tea" not in refreshed_content, \
742-
f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed_content}"
741+
# Use word boundary matching to avoid false positives (e.g., "team" contains "tea")
742+
import re
743+
def contains_word(text: str, word: str) -> bool:
744+
"""Check if text contains word as a whole word (not substring)."""
745+
return bool(re.search(rf'\b{re.escape(word)}\b', text, re.IGNORECASE))
746+
747+
assert not contains_word(refreshed_content, "bob") and \
748+
not contains_word(refreshed_content, "python") and \
749+
not contains_word(refreshed_content, "tea"), \
750+
f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed['content']}"
743751

744752
# MUST NOT include untagged content (security violation)
745753
assert "100 employees" not in refreshed_content and "growing fast" not in refreshed_content, \
746-
f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed_content}"
754+
f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed['content']}"
747755

748756
# Cleanup
749757
await memory.delete_bank(bank_id, request_context=request_context)

hindsight-embed/hindsight_embed/cli.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,21 +86,34 @@ def setup_logging(verbose: bool = False):
8686

8787

8888
def load_config_file():
89-
"""Load configuration from file if it exists."""
90-
# Check both config file locations
91-
config_files = [CONFIG_FILE, CONFIG_FILE_ALT]
92-
for config_path in config_files:
93-
if config_path.exists():
94-
with open(config_path) as f:
95-
for line in f:
96-
line = line.strip()
97-
if line and not line.startswith("#") and "=" in line:
98-
# Handle 'export VAR=value' format
99-
if line.startswith("export "):
100-
line = line[7:]
101-
key, value = line.split("=", 1)
102-
if key not in os.environ: # Don't override env vars
103-
os.environ[key] = value
89+
"""Load configuration from the active profile's file if it exists.
90+
91+
IMPORTANT: Only loads from the active profile, never from default if a specific profile is set.
92+
Uses dynamic path resolution to support testing with temporary HOME directories.
93+
"""
94+
from .profile_manager import ProfileManager, resolve_active_profile
95+
96+
# Resolve which profile to use (respects --profile flag, env vars, active_profile file)
97+
active_profile = resolve_active_profile()
98+
99+
# Get the config file path for this profile
100+
# Use ProfileManager which resolves paths dynamically
101+
pm = ProfileManager()
102+
paths = pm.resolve_profile_paths(active_profile)
103+
config_path = paths.config
104+
105+
# Load ONLY this profile's config, never fall back to default
106+
if config_path.exists():
107+
with open(config_path) as f:
108+
for line in f:
109+
line = line.strip()
110+
if line and not line.startswith("#") and "=" in line:
111+
# Handle 'export VAR=value' format
112+
if line.startswith("export "):
113+
line = line[7:]
114+
key, value = line.split("=", 1)
115+
if key not in os.environ: # Don't override env vars
116+
os.environ[key] = value
104117

105118

106119
def get_config():
@@ -1156,6 +1169,10 @@ def main():
11561169
if global_profile == "default":
11571170
global_profile = None
11581171

1172+
# Set the CLI profile override so it's available to resolve_active_profile()
1173+
# This must happen BEFORE any config loading (load_config_file, get_config, etc.)
1174+
set_cli_profile_override(global_profile)
1175+
11591176
# Check for built-in commands first
11601177
# Find the first non-flag argument (the actual command)
11611178
command = None

hindsight-embed/hindsight_embed/daemon_embed_manager.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ def _start_daemon(self, config: dict, profile: str) -> bool:
108108
daemon_log = paths.log
109109
port = paths.port
110110

111+
# Load profile's .env file and merge with provided config
112+
# This fixes issue #305 where profile env vars were ignored
113+
profile_config = self._profile_manager.load_profile_config(profile)
114+
# Merge: profile config first, then override with explicitly provided config
115+
merged_config = {**profile_config, **config}
116+
config = merged_config
117+
111118
# Build environment with LLM config
112119
# Support both formats: simple keys ("llm_api_key") and env var format ("HINDSIGHT_API_LLM_API_KEY")
113120
env = os.environ.copy()

0 commit comments

Comments
 (0)