diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 6d1764ac..c428901a 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -327,7 +327,14 @@ class Hindsight: background: str | None = None, disposition: dict[str, float] | None = None, ) -> BankProfileResponse: - """Create or update a memory bank.""" + """Create or update a memory bank. + + Args: + bank_id: Unique identifier for the bank + name: Human-readable display name + mission: Instructions guiding what Hindsight should learn and remember (for mental models) + disposition: Optional disposition traits (skepticism, literalism, empathy) + """ from hindsight_client_api.models import create_bank_request, disposition_traits disposition_obj = None @@ -336,7 +343,7 @@ class Hindsight: request_obj = create_bank_request.CreateBankRequest( name=name, - background=background, + mission=mission, disposition=disposition_obj, ) diff --git a/hindsight-clients/python/tests/test_main_operations.py b/hindsight-clients/python/tests/test_main_operations.py index b5b78ee2..f2f0b0f9 100644 --- a/hindsight-clients/python/tests/test_main_operations.py +++ b/hindsight-clients/python/tests/test_main_operations.py @@ -139,7 +139,7 @@ class TestReflect: """Setup: Store some test memories and bank background.""" client.create_bank( bank_id=bank_id, - background="I am a helpful AI assistant interested in technology and science.", + mission="I am a helpful AI assistant interested in technology and science.", ) client.retain_batch( @@ -261,7 +261,7 @@ class TestEndToEndWorkflow: # 1. Create bank client.create_bank( bank_id=workflow_bank_id, - background="I am a software engineer who loves Python programming.", + mission="I am a software engineer who loves Python programming.", ) # 2. Store memories @@ -666,7 +666,7 @@ class TestDeleteBank: # Create bank with some data client.create_bank( bank_id=bank_id, - background="This bank will be deleted", + mission="This bank will be deleted", ) client.retain( bank_id=bank_id, diff --git a/hindsight-docs/examples/api/memory-banks.py b/hindsight-docs/examples/api/memory-banks.py index 46eadf0c..59d6d20c 100644 --- a/hindsight-docs/examples/api/memory-banks.py +++ b/hindsight-docs/examples/api/memory-banks.py @@ -23,7 +23,7 @@ client = Hindsight(base_url=HINDSIGHT_URL) client.create_bank( bank_id="my-bank", name="Research Assistant", - background="I am a research assistant specializing in machine learning", + mission="I am a research assistant specializing in machine learning", disposition={ "skepticism": 4, "literalism": 3, @@ -33,15 +33,15 @@ client.create_bank( # [/docs:create-bank] -# [docs:bank-background] +# [docs:bank-mission] client.create_bank( bank_id="financial-advisor", name="Financial Advisor", - background="""I am a conservative financial advisor with 20 years of experience. + mission="""I am a conservative financial advisor with 20 years of experience. I prioritize capital preservation over aggressive growth. I have seen multiple market crashes and believe in diversification.""" ) -# [/docs:bank-background] +# [/docs:bank-mission] # ============================================================================= diff --git a/hindsight-docs/examples/api/reflect.py b/hindsight-docs/examples/api/reflect.py index 748a548d..1236a55b 100644 --- a/hindsight-docs/examples/api/reflect.py +++ b/hindsight-docs/examples/api/reflect.py @@ -54,7 +54,7 @@ response = client.reflect( client.create_bank( bank_id="cautious-advisor", name="Cautious Advisor", - background="I am a risk-aware financial advisor", + mission="I am a risk-aware financial advisor", disposition={ "skepticism": 5, # Very skeptical of claims "literalism": 4, # Focuses on exact requirements diff --git a/hindsight-integrations/litellm/README.md b/hindsight-integrations/litellm/README.md index d484cba1..794ea8d8 100644 --- a/hindsight-integrations/litellm/README.md +++ b/hindsight-integrations/litellm/README.md @@ -5,13 +5,14 @@ Universal LLM memory integration via LiteLLM. Add persistent memory to any LLM a ## Features - **Universal LLM Support** - Works with 100+ LLM providers via LiteLLM (OpenAI, Anthropic, Groq, Azure, AWS Bedrock, Google Vertex AI, and more) -- **Simple Integration** - Just configure, enable, and use `hindsight_litellm.completion()` +- **Simple Integration** - Just configure, set defaults, enable, and use `hindsight_litellm.completion()` - **Automatic Memory Injection** - Relevant memories are injected into prompts before LLM calls -- **Automatic Conversation Storage** - Conversations are stored to Hindsight for future recall +- **Automatic Conversation Storage** - Conversations are stored to Hindsight for future recall (async by default for performance) - **Two Memory Modes** - Choose between `reflect` (synthesized context) or `recall` (raw memory retrieval) - **Direct Memory APIs** - Query, synthesize, and store memories manually - **Native Client Wrappers** - Alternative wrappers for OpenAI and Anthropic SDKs - **Debug Mode** - Inspect exactly what memories are being injected +- **Async Error Tracking** - Check for background operation failures with `get_pending_retain_errors()` ## Installation @@ -24,20 +25,31 @@ pip install hindsight-litellm ```python import hindsight_litellm -# Configure and enable memory integration +# Step 1: Configure static settings hindsight_litellm.configure( hindsight_api_url="http://localhost:8888", - bank_id="my-agent", + verbose=True, ) + +# Step 2: Set defaults (bank_id is required) +hindsight_litellm.set_defaults( + bank_id="my-agent", + use_reflect=True, # Use reflect for synthesized context +) + +# Step 3: Enable memory integration hindsight_litellm.enable() -# Use the convenience wrapper - memory is automatically injected and stored +# Step 4: Use with explicit hindsight_query (required when inject_memories=True) response = hindsight_litellm.completion( model="gpt-4o-mini", - messages=[{"role": "user", "content": "What did we discuss about AI?"}] + messages=[{"role": "user", "content": "What did we discuss about AI?"}], + hindsight_query="What do I know about AI discussions?", # Required! ) ``` +**Important:** When `inject_memories=True` (default), you must provide `hindsight_query` to specify what to search for in memory. This ensures intentional, focused memory queries. + ## How It Works Here's what happens under the hood when you call `completion()`: @@ -121,56 +133,89 @@ The memory injection and storage happen automatically - you just use `completion ## Configuration Options +The API is split into two functions for clarity: + +### 1. `configure()` - Static Settings + +Settings that typically don't change during a session: + ```python hindsight_litellm.configure( # Required hindsight_api_url="http://localhost:8888", # Hindsight API server URL - bank_id="my-agent", # Memory bank ID - api_key="your-api-key", # Optional API key for authentication + # Optional - Authentication + api_key="your-api-key", # API key for Hindsight authentication # Optional - Memory behavior store_conversations=True, # Store conversations after LLM calls inject_memories=True, # Inject relevant memories into prompts - use_reflect=False, # Use reflect API (synthesized) vs recall (raw memories) - reflect_include_facts=False, # Include source facts with reflect responses - max_memories=None, # Maximum memories to inject (None = unlimited) - max_memory_tokens=4096, # Maximum tokens for memory context - recall_budget="mid", # Recall budget: "low", "mid", "high" - fact_types=["world", "agent"], # Filter fact types to inject - - # Optional - Bank Configuration - bank_name="My Agent", # Human-readable display name for the memory bank - background="This agent...", # Instructions guiding what Hindsight should remember (see below) + sync_storage=False, # False = async storage (default, better performance) + # True = sync storage (blocks, raises errors immediately) # Optional - Advanced - injection_mode="system_message", # or "prepend_user" - excluded_models=["gpt-3.5*"], # Exclude certain models + injection_mode="system_message", # How to inject: "system_message" or "prepend_user" + excluded_models=["gpt-3.5*"], # Exclude certain models from interception verbose=True, # Enable verbose logging and debug info ) ``` -### Bank Configuration: background and bank_name +### 2. `set_defaults()` - Per-Call Defaults -The `background` and `bank_name` parameters configure the memory bank itself. When provided, `configure()` will automatically create or update the bank with these settings. - -- **bank_name**: A human-readable display name for the memory bank. Useful for identifying banks in the Hindsight UI or when managing multiple banks. - -- **background**: Instructions that guide Hindsight on what information is important to extract and remember from conversations. This influences memory extraction during the `retain` operation and can affect how the bank's "disposition" (skepticism, literalism, empathy) is calibrated. +Default values for per-call settings. These can be overridden on individual calls using `hindsight_*` kwargs: ```python -# Example: Customer support routing agent -hindsight_litellm.configure( - hindsight_api_url="http://localhost:8888", - bank_id="support-router", - bank_name="Customer Support Router", - background="""This agent routes customer support requests to the appropriate team. - Remember which types of issues should go to which teams (billing, technical, sales). - Track customer preferences for communication channels and past issue resolutions. - Note any escalation patterns or VIP customers who need special handling.""", +hindsight_litellm.set_defaults( + # Required + bank_id="my-agent", # Memory bank ID + + # Optional - Memory retrieval + budget="mid", # Budget level: "low", "mid", "high" + fact_types=["world", "opinion"], # Filter fact types to retrieve + max_memories=10, # Maximum memories to inject (None = unlimited) + max_memory_tokens=4096, # Maximum tokens for memory context + include_entities=True, # Include entity observations in recall + + # Optional - Reflect mode + use_reflect=True, # Use reflect API (synthesized) vs recall (raw memories) + reflect_include_facts=False, # Include source facts in debug info + reflect_context="I am a delivery agent finding recipients.", # Context for reflect reasoning + reflect_response_schema={...}, # JSON Schema for structured reflect output + + # Optional - Debugging + trace=False, # Enable trace info for debugging + document_id="conversation-1", # Document ID for grouping conversations ) ``` +### 3. Per-Call Overrides + +Override any default on individual calls using `hindsight_*` kwargs: + +```python +response = hindsight_litellm.completion( + model="gpt-4o-mini", + messages=[...], + hindsight_query="Where is Alice located?", # REQUIRED when inject_memories=True + hindsight_reflect_context="Currently on floor 3", # Per-call reflect context override + # hindsight_bank_id="other-bank", # Override bank_id for this call +) +``` + +### Bank Configuration: mission + +Use `set_bank_mission()` to configure what the memory bank should learn and remember (used for mental models): + +```python +hindsight_litellm.set_bank_mission( + mission="""This agent routes customer support requests to the appropriate team. + Remember which types of issues should go to which teams (billing, technical, sales). + Track customer preferences for communication channels and past issue resolutions.""", + name="Customer Support Router", # Optional display name +) +``` + + ### Memory Modes: Reflect vs Recall - **Recall mode** (`use_reflect=False`, default): Retrieves raw memory facts and injects them as a numbered list. Best when you need precise, individual memories. @@ -178,18 +223,19 @@ hindsight_litellm.configure( ```python # Recall mode - raw memories -hindsight_litellm.configure( - bank_id="my-agent", - use_reflect=False, # Default -) +hindsight_litellm.set_defaults(bank_id="my-agent", use_reflect=False) # Injects: "1. [WORLD] User prefers Python\n2. [OPINION] User dislikes Java..." # Reflect mode - synthesized context -hindsight_litellm.configure( +hindsight_litellm.set_defaults(bank_id="my-agent", use_reflect=True) +# Injects: "Based on previous conversations, the user is a Python developer who..." + +# Reflect with context - shapes LLM reasoning (not retrieval) +hindsight_litellm.set_defaults( bank_id="my-agent", use_reflect=True, + reflect_context="I am a delivery agent looking for package recipients.", ) -# Injects: "Based on previous conversations, the user is a Python developer who..." ``` ## Multi-Provider Support @@ -199,29 +245,29 @@ Works with any LiteLLM-supported provider: ```python import hindsight_litellm -hindsight_litellm.configure( - hindsight_api_url="http://localhost:8888", - bank_id="my-agent", -) +hindsight_litellm.configure(hindsight_api_url="http://localhost:8888") +hindsight_litellm.set_defaults(bank_id="my-agent") hindsight_litellm.enable() +messages = [{"role": "user", "content": "Hello!"}] + # OpenAI -hindsight_litellm.completion(model="gpt-4o", messages=[...]) +hindsight_litellm.completion(model="gpt-4o", messages=messages, hindsight_query="greeting") # Anthropic -hindsight_litellm.completion(model="claude-3-5-sonnet-20241022", messages=[...]) +hindsight_litellm.completion(model="claude-3-5-sonnet-20241022", messages=messages, hindsight_query="greeting") # Groq -hindsight_litellm.completion(model="groq/llama-3.1-70b-versatile", messages=[...]) +hindsight_litellm.completion(model="groq/llama-3.1-70b-versatile", messages=messages, hindsight_query="greeting") # Azure OpenAI -hindsight_litellm.completion(model="azure/gpt-4", messages=[...]) +hindsight_litellm.completion(model="azure/gpt-4", messages=messages, hindsight_query="greeting") # AWS Bedrock -hindsight_litellm.completion(model="bedrock/anthropic.claude-3", messages=[...]) +hindsight_litellm.completion(model="bedrock/anthropic.claude-3", messages=messages, hindsight_query="greeting") # Google Vertex AI -hindsight_litellm.completion(model="vertex_ai/gemini-pro", messages=[...]) +hindsight_litellm.completion(model="vertex_ai/gemini-pro", messages=messages, hindsight_query="greeting") ``` ## Direct Memory APIs @@ -229,9 +275,10 @@ hindsight_litellm.completion(model="vertex_ai/gemini-pro", messages=[...]) ### Recall - Query raw memories ```python -from hindsight_litellm import configure, recall +from hindsight_litellm import configure, set_defaults, recall -configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") +configure(hindsight_api_url="http://localhost:8888") +set_defaults(bank_id="my-agent") # Query memories memories = recall("what projects am I working on?", budget="mid") @@ -246,9 +293,10 @@ for m in memories: ### Reflect - Get synthesized context ```python -from hindsight_litellm import configure, reflect +from hindsight_litellm import configure, set_defaults, reflect -configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") +configure(hindsight_api_url="http://localhost:8888") +set_defaults(bank_id="my-agent") # Get synthesized memory context result = reflect("what do you know about the user's preferences?") @@ -256,21 +304,42 @@ print(result.text) # Output: # "Based on our conversations, the user prefers Python for backend development..." + +# With context to shape the response (doesn't affect retrieval) +result = reflect( + query="what do I know about Alice?", + context="I am a delivery agent looking for package recipients.", +) ``` ### Retain - Store memories ```python -from hindsight_litellm import configure, retain +from hindsight_litellm import configure, set_defaults, retain, get_pending_retain_errors -configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") +configure(hindsight_api_url="http://localhost:8888") +set_defaults(bank_id="my-agent") -# Store a memory +# Async retain (default) - fast, non-blocking +# Returns immediately; actual storage happens in background result = retain( content="User mentioned they're working on a machine learning project", context="Discussion about current projects", ) -print(f"Retained successfully: {result.success}, items: {result.items_count}") +# result.success is True immediately (actual errors collected separately) + +# Sync retain - blocks until complete, raises errors immediately +result = retain( + content="Critical information that must be stored", + context="Important data", + sync=True, # Block until storage completes +) + +# Check for async retain errors (call periodically) +errors = get_pending_retain_errors() +if errors: + for e in errors: + print(f"Background retain failed: {e}") ``` ### Async APIs @@ -332,19 +401,16 @@ response = wrapped.messages.create( When `verbose=True`, you can inspect exactly what memories are being injected: ```python -from hindsight_litellm import configure, enable, completion, get_last_injection_debug +from hindsight_litellm import configure, set_defaults, enable, completion, get_last_injection_debug -configure( - bank_id="my-agent", - hindsight_api_url="http://localhost:8888", - verbose=True, - use_reflect=True, -) +configure(hindsight_api_url="http://localhost:8888", verbose=True) +set_defaults(bank_id="my-agent", use_reflect=True) enable() response = completion( model="gpt-4o-mini", - messages=[{"role": "user", "content": "What's my favorite color?"}] + messages=[{"role": "user", "content": "What's my favorite color?"}], + hindsight_query="What is the user's favorite color?", ) # Inspect what was injected @@ -365,7 +431,11 @@ from hindsight_litellm import hindsight_memory import litellm with hindsight_memory(bank_id="user-123"): - response = litellm.completion(model="gpt-4", messages=[...]) + response = litellm.completion( + model="gpt-4", + messages=[{"role": "user", "content": "Hello!"}], + hindsight_query="greeting context", + ) # Memory integration automatically disabled after context ``` @@ -387,7 +457,8 @@ cleanup() | Function | Description | |----------|-------------| -| `configure(...)` | Configure global Hindsight settings | +| `configure(...)` | Configure static Hindsight settings (API URL, auth, storage options) | +| `set_defaults(...)` | Set defaults for per-call settings (bank_id, budget, reflect options) | | `enable()` | Enable memory integration with LiteLLM | | `disable()` | Disable memory integration | | `is_enabled()` | Check if memory integration is enabled | @@ -397,20 +468,30 @@ cleanup() | Function | Description | |----------|-------------| -| `get_config()` | Get current configuration | -| `is_configured()` | Check if Hindsight is configured | -| `reset_config()` | Reset configuration to defaults | +| `get_config()` | Get current static configuration | +| `get_defaults()` | Get current per-call defaults | +| `is_configured()` | Check if Hindsight is configured with a bank_id | +| `reset_config()` | Reset all configuration to defaults | +| `set_document_id(id)` | Convenience function to update document_id | +| `set_bank_mission(...)` | Set mission/instructions for a memory bank (for mental models) | ### Memory Functions | Function | Description | |----------|-------------| -| `recall(query, ...)` | Synchronously query raw memories | -| `arecall(query, ...)` | Asynchronously query raw memories | -| `reflect(query, ...)` | Synchronously get synthesized memory context | -| `areflect(query, ...)` | Asynchronously get synthesized memory context | -| `retain(content, ...)` | Synchronously store a memory | -| `aretain(content, ...)` | Asynchronously store a memory | +| `recall(query, ...)` | Query raw memories (sync) | +| `arecall(query, ...)` | Query raw memories (async) | +| `reflect(query, ...)` | Get synthesized memory context (sync) | +| `areflect(query, ...)` | Get synthesized memory context (async) | +| `retain(content, sync=False, ...)` | Store a memory (async by default, use `sync=True` to block) | +| `aretain(content, ...)` | Store a memory (async) | + +### Error Tracking Functions + +| Function | Description | +|----------|-------------| +| `get_pending_retain_errors()` | Get and clear errors from background retain operations | +| `get_pending_storage_errors()` | Get and clear errors from background conversation storage | ### Debug Functions diff --git a/hindsight-integrations/litellm/hindsight_litellm/__init__.py b/hindsight-integrations/litellm/hindsight_litellm/__init__.py index 437a54b6..b86ac5d7 100644 --- a/hindsight-integrations/litellm/hindsight_litellm/__init__.py +++ b/hindsight-integrations/litellm/hindsight_litellm/__init__.py @@ -8,36 +8,79 @@ Features: - Automatic memory injection before LLM calls - Automatic conversation storage after LLM calls - Works with any LiteLLM-supported provider -- Zero code changes to existing LiteLLM usage - Multi-user support via separate bank_ids +- Per-call overrides via hindsight_* kwargs - Document grouping for conversation threading - Direct recall API for manual memory queries - Native client wrappers for OpenAI and Anthropic +- STRICT ERROR HANDLING: Raises HindsightError on any memory operation failure + +Error Handling: + Unlike LiteLLM's callback system which silently swallows exceptions, this + integration uses STRICT error handling. If memory injection fails (when + inject_memories=True) or storage fails (when store_conversations=True), + a HindsightError will be raised and propagate to your code. + +API Structure: + 1. configure() - Static settings (rarely change during session) + - hindsight_api_url, api_key, verbose + - injection_mode, excluded_models, store_conversations, inject_memories + + 2. set_defaults() - Default values for per-call settings (required: bank_id) + - bank_id (REQUIRED), document_id, budget, fact_types + - max_memories, max_memory_tokens, use_reflect, reflect_include_facts + - include_entities (default True), trace (default False) + + 3. Per-call kwargs (hindsight_* prefix) - Override any default per-call + - hindsight_bank_id, hindsight_document_id, hindsight_budget, etc. + - hindsight_include_entities, hindsight_trace + + 4. set_bank_mission() - Set mission/instructions for a bank (for mental models) + - Can be called anytime, bank is auto-created if needed + - set_bank_background() is deprecated, use set_bank_mission() instead Basic usage: - >>> from hindsight_litellm import configure, enable + >>> import hindsight_litellm + >>> from hindsight_litellm import HindsightError >>> - >>> # Configure Hindsight integration - >>> configure( + >>> # Configure static settings + >>> hindsight_litellm.configure( ... hindsight_api_url="http://localhost:8888", - ... bank_id="user-123", # Use separate bank_ids for multi-user support - ... store_conversations=True, - ... inject_memories=True, + ... verbose=True, + ... ) + >>> + >>> # Set defaults (bank_id is required) + >>> hindsight_litellm.set_defaults(bank_id="user-123") + >>> + >>> # Optionally set bank mission (for mental models) + >>> hindsight_litellm.set_bank_mission( + ... mission="This agent helps with customer support. Remember customer preferences." ... ) >>> >>> # Enable memory integration - >>> enable() + >>> hindsight_litellm.enable() >>> - >>> # Now use LiteLLM as normal - memory integration is automatic - >>> import litellm - >>> response = litellm.completion( + >>> # Use litellm.completion() or hindsight_litellm.completion() - both work + >>> try: + ... response = hindsight_litellm.completion( + ... model="gpt-4", + ... messages=[{"role": "user", "content": "What did we discuss?"}] + ... ) + ... except HindsightError as e: + ... print(f"Memory operation failed: {e}") + >>> + >>> # Override per-call: + >>> response = hindsight_litellm.completion( ... model="gpt-4", - ... messages=[{"role": "user", "content": "What did we discuss about AI?"}] + ... messages=[...], + ... hindsight_bank_id="different-bank", # Override default bank_id + ... hindsight_document_id="conv-123", # Set document_id for this call ... ) Direct recall API: - >>> from hindsight_litellm import configure, recall - >>> configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") + >>> from hindsight_litellm import configure, set_defaults, recall + >>> configure(hindsight_api_url="http://localhost:8888") + >>> set_defaults(bank_id="my-agent") >>> >>> # Query memories directly >>> memories = recall("what projects am I working on?") @@ -58,69 +101,39 @@ Native client wrappers: Works with any LiteLLM-supported provider: >>> # OpenAI - >>> litellm.completion(model="gpt-4", messages=[...]) + >>> hindsight_litellm.completion(model="gpt-4", messages=[...]) >>> >>> # Anthropic - >>> litellm.completion(model="claude-3-opus-20240229", messages=[...]) + >>> hindsight_litellm.completion(model="claude-3-opus-20240229", messages=[...]) >>> >>> # Groq - >>> litellm.completion(model="groq/llama-3.1-70b-versatile", messages=[...]) - >>> - >>> # Azure OpenAI - >>> litellm.completion(model="azure/gpt-4", messages=[...]) - >>> - >>> # AWS Bedrock - >>> litellm.completion(model="bedrock/anthropic.claude-3", messages=[...]) - >>> - >>> # Google Vertex AI - >>> litellm.completion(model="vertex_ai/gemini-pro", messages=[...]) - -Context manager usage: - >>> from hindsight_litellm import hindsight_memory - >>> - >>> with hindsight_memory(bank_id="user-123"): - ... response = litellm.completion(model="gpt-4", messages=[...]) - >>> # Memory integration automatically disabled after context - -Configuration options: - - hindsight_api_url: URL of your Hindsight API server - - bank_id: Memory bank ID for memory operations (required). For multi-user - support, use different bank_ids per user (e.g., f"user-{user_id}") - - api_key: Optional API key for Hindsight authentication - - store_conversations: Whether to store conversations (default: True) - - inject_memories: Whether to inject relevant memories (default: True) - - injection_mode: How to inject memories (system_message or prepend_user) - - max_memories: Maximum number of memories to inject (None = unlimited) - - recall_budget: Budget for memory recall (low, mid, high) - - excluded_models: List of model patterns to exclude from interception - - verbose: Enable verbose logging - - bank_name: Display name for the memory bank - - background: Instructions that help Hindsight understand what to remember - -Background example: - >>> configure( - ... bank_id="routing-agent", - ... background="This agent routes customer requests to support channels. " - ... "Remember which types of issues should go to which channels.", - ... ) + >>> hindsight_litellm.completion(model="groq/llama-3.1-70b-versatile", messages=[...]) """ from contextlib import contextmanager from dataclasses import dataclass from typing import Optional, List, Any +import threading +import logging import litellm from .config import ( configure, + set_defaults, + set_bank_mission, get_config, + get_defaults, is_configured, reset_config, + set_document_id, HindsightConfig, + HindsightDefaults, MemoryInjectionMode, ) from .callbacks import ( HindsightCallback, + HindsightError, get_callback, cleanup_callback, ) @@ -138,10 +151,13 @@ from .wrappers import ( aretain, RetainResult, RetainDebugInfo, + get_pending_retain_errors, wrap_openai, wrap_anthropic, HindsightOpenAI, HindsightAnthropic, + _get_client, + _close_client, ) @@ -218,13 +234,18 @@ def clear_injection_debug() -> None: _last_injection_debug = None -def _inject_memories(messages: List[dict]) -> List[dict]: +def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, custom_reflect_context: Optional[str] = None) -> List[dict]: """Inject memories into messages list. Returns the modified messages list with memories injected into the system message. - Uses reflect API when config.use_reflect=True, otherwise uses recall API. + Uses reflect API when defaults.use_reflect=True, otherwise uses recall API. When verbose=True in config, stores debug info retrievable via get_last_injection_debug(). + + Args: + messages: List of message dicts to inject memories into + custom_query: Optional custom query to use for memory lookup instead of user message + custom_reflect_context: Optional context to pass to reflect API (overrides defaults.reflect_context) """ global _last_injection_debug import logging @@ -232,54 +253,70 @@ def _inject_memories(messages: List[dict]) -> List[dict]: # Clear previous debug info _last_injection_debug = None - if not is_configured(): + config = get_config() + defaults = get_defaults() + + if not config or not config.inject_memories: return messages - config = get_config() - if not config or not config.enabled or not config.inject_memories: - return messages + if not defaults or not defaults.bank_id: + raise ValueError( + "No bank_id configured. Either call set_defaults(bank_id=...) " + "or pass hindsight_bank_id=... to the completion call." + ) if not messages: return messages - # Extract user query from last user message - user_query = None - for msg in reversed(messages): - if msg.get("role") == "user": - content = msg.get("content") - if isinstance(content, str): - user_query = content - break + # hindsight_query is required when inject_memories=True + if not custom_query: + raise ValueError( + "hindsight_query is required when inject_memories=True. " + "Pass hindsight_query='your query' to specify what to search for in memory. " + "Example: hindsight_query=recipient_name or hindsight_query='What do I know about Alice?'" + ) - if not user_query: - return messages + user_query = custom_query + # Use bank_id from defaults + bank_id = defaults.bank_id + + # Track debug info + mode = "reflect" if defaults.use_reflect else "recall" + reflect_text = None + reflect_facts = None + recall_results = None + results_count = 0 + memory_context = "" + + # Create fresh client for this operation (closed in finally block) + client = None try: - from hindsight_client import Hindsight - - # Use bank_id directly (no entity scoping) - bank_id = config.bank_id - - # Track debug info - mode = "reflect" if config.use_reflect else "recall" - reflect_text = None - reflect_facts = None - recall_results = None - results_count = 0 - memory_context = "" - - # Create client - client = Hindsight(base_url=config.hindsight_api_url, timeout=30.0) + client = _get_client(config.hindsight_api_url) # Use reflect API if use_reflect is enabled - if config.use_reflect: + if defaults.use_reflect: + # Build common reflect parameters + reflect_kwargs = { + "query": user_query, + "budget": defaults.budget or "mid", + } + # Add context if provided (shapes reasoning but not retrieval) + # Per-call context overrides default context + if custom_reflect_context: + reflect_kwargs["context"] = custom_reflect_context + elif defaults.reflect_context: + reflect_kwargs["context"] = defaults.reflect_context + # Add response_schema for structured output + if defaults.reflect_response_schema: + reflect_kwargs["response_schema"] = defaults.reflect_response_schema + # If reflect_include_facts is enabled, use the API directly to include facts - if config.reflect_include_facts: + if defaults.reflect_include_facts: from hindsight_client_api.models import reflect_request, reflect_include_options request_obj = reflect_request.ReflectRequest( - query=user_query, - budget=config.recall_budget or "mid", include=reflect_include_options.ReflectIncludeOptions(facts={}), + **reflect_kwargs, ) import asyncio try: @@ -301,8 +338,7 @@ def _inject_memories(messages: List[dict]) -> List[dict]: else: result = client.reflect( bank_id=bank_id, - query=user_query, - budget=config.recall_budget or "mid", + **reflect_kwargs, ) reflect_text = result.text if hasattr(result, 'text') else str(result) @@ -331,9 +367,9 @@ def _inject_memories(messages: List[dict]) -> List[dict]: result = client.recall( bank_id=bank_id, query=user_query, - budget=config.recall_budget or "mid", - max_tokens=config.max_memory_tokens or 4096, - types=config.fact_types, + budget=defaults.budget or "mid", + max_tokens=defaults.max_memory_tokens or 4096, + types=defaults.fact_types, ) # client.recall() returns a list directly, not an object with .results if isinstance(result, list): @@ -366,7 +402,7 @@ def _inject_memories(messages: List[dict]) -> List[dict]: return messages # Format memories (apply limit if set, otherwise use all) - results_to_use = results[:config.max_memories] if config.max_memories else results + results_to_use = results[:defaults.max_memories] if defaults.max_memories else results memory_lines = [] for i, r in enumerate(results_to_use, 1): text = r.text if hasattr(r, 'text') else str(r) @@ -435,14 +471,14 @@ def _inject_memories(messages: List[dict]) -> List[dict]: return updated_messages except ImportError as e: - if config.verbose: + if config and config.verbose: logging.getLogger("hindsight_litellm").warning( f"hindsight_client not installed: {e}. Install with: pip install hindsight-client" ) _last_injection_debug = InjectionDebugInfo( - mode="reflect" if config.use_reflect else "recall", + mode="reflect" if (defaults and defaults.use_reflect) else "recall", query=user_query or "", - bank_id=config.bank_id or "", + bank_id=(defaults.bank_id if defaults else "") or "", memory_context="", results_count=0, injected=False, @@ -451,104 +487,182 @@ def _inject_memories(messages: List[dict]) -> List[dict]: return messages except Exception as e: # Always set debug info on error when verbose mode is on - if config.verbose: + if config and config.verbose: logging.getLogger("hindsight_litellm").warning(f"Failed to inject memories: {e}") _last_injection_debug = InjectionDebugInfo( - mode="reflect" if config.use_reflect else "recall", + mode="reflect" if (defaults and defaults.use_reflect) else "recall", query=user_query or "", - bank_id=config.bank_id or "", + bank_id=(defaults.bank_id if defaults else "") or "", memory_context="", results_count=0, injected=False, error=str(e), ) return messages + finally: + # Always close the client to avoid "Unclosed client session" warnings + if client is not None: + try: + client.close() + except Exception: + pass def _wrapped_completion(*args, **kwargs): - """Wrapper for litellm.completion that injects memories before the call.""" - # Inject memories into messages - if "messages" in kwargs: - kwargs["messages"] = _inject_memories(kwargs["messages"]) - elif args and len(args) > 1: - # messages might be second positional arg after model - args = list(args) - if isinstance(args[1], list): - args[1] = _inject_memories(args[1]) - args = tuple(args) + """Wrapper for litellm.completion that handles memory injection and storage. - # Call original - return _original_completion(*args, **kwargs) + This wrapper: + 1. Injects memories before the LLM call (raises HindsightError on failure) + 2. Calls the original litellm.completion + 3. Stores the conversation after success (raises HindsightError on failure) + """ + config = get_config() + + # Extract hindsight-specific kwargs + custom_query = kwargs.pop("hindsight_query", None) + custom_reflect_context = kwargs.pop("hindsight_reflect_context", None) + + # Extract messages from kwargs or args + messages = kwargs.get("messages") + if messages is None and len(args) > 1: + messages = args[1] + + model = kwargs.get("model") + if model is None and len(args) > 0: + model = args[0] + + # Step 1: Inject memories (raises HindsightError on failure) + if config and config.inject_memories and messages: + try: + injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context) + kwargs["messages"] = injected_messages + except Exception as e: + raise HindsightError(f"Failed to inject memories: {e}") from e + + # Step 2: Call original LLM + response = _original_completion(*args, **kwargs) + + # Step 3: Store conversation (raises HindsightError on failure) + if config and config.store_conversations: + final_messages = kwargs.get("messages", messages) + if final_messages: + _store_conversation(final_messages, response, model or "unknown") + + return response async def _wrapped_acompletion(*args, **kwargs): - """Wrapper for litellm.acompletion that injects memories before the call.""" - # Inject memories into messages - if "messages" in kwargs: - kwargs["messages"] = _inject_memories(kwargs["messages"]) - elif args and len(args) > 1: - args = list(args) - if isinstance(args[1], list): - args[1] = _inject_memories(args[1]) - args = tuple(args) + """Wrapper for litellm.acompletion that handles memory injection and storage. - # Call original - return await _original_acompletion(*args, **kwargs) + This wrapper: + 1. Injects memories before the LLM call (raises HindsightError on failure) + 2. Calls the original litellm.acompletion + 3. Stores the conversation after success (raises HindsightError on failure) + """ + config = get_config() + + # Extract hindsight-specific kwargs + custom_query = kwargs.pop("hindsight_query", None) + custom_reflect_context = kwargs.pop("hindsight_reflect_context", None) + + # Extract messages from kwargs or args + messages = kwargs.get("messages") + if messages is None and len(args) > 1: + messages = args[1] + + model = kwargs.get("model") + if model is None and len(args) > 0: + model = args[0] + + # Step 1: Inject memories (raises HindsightError on failure) + if config and config.inject_memories and messages: + try: + injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context) + kwargs["messages"] = injected_messages + except Exception as e: + raise HindsightError(f"Failed to inject memories: {e}") from e + + # Step 2: Call original LLM + response = await _original_acompletion(*args, **kwargs) + + # Step 3: Store conversation (raises HindsightError on failure) + if config and config.store_conversations: + final_messages = kwargs.get("messages", messages) + if final_messages: + _store_conversation(final_messages, response, model or "unknown") + + return response def enable() -> None: """Enable Hindsight memory integration with LiteLLM. - This monkeypatches LiteLLM functions to: + This monkeypatches litellm.completion and litellm.acompletion to: 1. Inject relevant memories into prompts before LLM calls 2. Store conversations to Hindsight after successful LLM calls - Must be called after configure() to take effect. + STRICT ERROR HANDLING: Unlike LiteLLM's callback system which swallows + exceptions, this integration raises HindsightError on any failure. If + memory injection fails (when inject_memories=True) or storage fails + (when store_conversations=True), the error will propagate to your code. + + Must be called after configure() and set_defaults(bank_id=...). Example: - >>> from hindsight_litellm import configure, enable - >>> configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") + >>> from hindsight_litellm import configure, set_defaults, enable, HindsightError + >>> import litellm + >>> + >>> configure(hindsight_api_url="http://localhost:8888") + >>> set_defaults(bank_id="my-agent") >>> enable() >>> - >>> # Now all LiteLLM calls will have memory integration - >>> import litellm - >>> response = litellm.completion(model="gpt-4", messages=[...]) + >>> # Now litellm.completion() has memory integration + >>> try: + ... response = litellm.completion( + ... model="gpt-4", + ... messages=[{"role": "user", "content": "Hello!"}] + ... ) + ... except HindsightError as e: + ... print(f"Memory operation failed: {e}") + + Raises: + RuntimeError: If configure() or set_defaults() hasn't been called """ global _enabled, _original_completion, _original_acompletion if _enabled: return # Already enabled - if not is_configured(): + config = get_config() + defaults = get_defaults() + + if not config: raise RuntimeError( "Hindsight not configured. Call configure() before enable()." ) - # Store original functions and monkeypatch for memory injection + if not defaults or not defaults.bank_id: + raise RuntimeError( + "Hindsight bank_id not set. Call set_defaults(bank_id=...) before enable()." + ) + + # Store original functions and monkeypatch for memory injection + storage _original_completion = litellm.completion _original_acompletion = litellm.acompletion litellm.completion = _wrapped_completion litellm.acompletion = _wrapped_acompletion - # Get or create the callback instance for storing conversations - callback = get_callback() - - # Register callback using litellm.callbacks for conversation storage - if callback not in litellm.callbacks: - litellm.callbacks.append(callback) - _enabled = True - config = get_config() - if config and config.verbose: - print(f"Hindsight memory enabled for bank: {config.bank_id}") + if config.verbose: + print(f"Hindsight memory enabled for bank: {defaults.bank_id}") def disable() -> None: """Disable Hindsight memory integration with LiteLLM. - This restores the original LiteLLM functions and removes callbacks, - stopping memory injection and conversation storage. + This restores the original LiteLLM functions, stopping memory injection + and conversation storage. Also closes any cached HTTP connections. Example: >>> from hindsight_litellm import disable @@ -567,10 +681,8 @@ def disable() -> None: litellm.acompletion = _original_acompletion _original_acompletion = None - # Remove callback from litellm.callbacks - callback = get_callback() - if callback in litellm.callbacks: - litellm.callbacks.remove(callback) + # Close cached HTTP client to avoid "Unclosed client session" warnings + _close_client() _enabled = False @@ -598,7 +710,7 @@ def cleanup() -> None: >>> from hindsight_litellm import cleanup >>> cleanup() # Clean up when done """ - disable() + disable() # This already calls _close_client() cleanup_callback() reset_config() @@ -607,26 +719,227 @@ def cleanup() -> None: # Convenience wrappers - use hindsight_litellm.completion() directly # ============================================================================= +def _format_conversation_for_storage( + messages: List[dict], + response, +) -> str: + """Format conversation messages and response for storage to Hindsight. + + Returns the formatted conversation text. + """ + items = [] + + for msg in messages: + role = msg.get("role", "").upper() + content = msg.get("content", "") + + # Skip system messages + if role == "SYSTEM": + continue + + # Skip injected memory context + if isinstance(content, str) and content.startswith("# Relevant Memories"): + continue + + # Handle tool results + if role == "TOOL": + items.append(f"TOOL_RESULT: {content}") + continue + + # Handle assistant messages with tool calls + tool_calls = msg.get("tool_calls", []) + if tool_calls: + tc_strs = [] + for tc in tool_calls: + if hasattr(tc, "function"): + tc_strs.append(f"{tc.function.name}({tc.function.arguments})") + elif isinstance(tc, dict) and "function" in tc: + func = tc["function"] + tc_strs.append(f"{func.get('name', '')}({func.get('arguments', '')})") + if tc_strs: + items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(tc_strs)}") + if content: + items.append(f"ASSISTANT: {content}") + continue + + # Handle structured content (vision messages) + if isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + content = " ".join(text_parts) + + if content: + label = "USER" if role == "USER" else "ASSISTANT" + items.append(f"{label}: {content}") + + # Add the response + if response.choices and len(response.choices) > 0: + choice = response.choices[0] + if hasattr(choice, "message") and choice.message: + assistant_content = choice.message.content or "" + assistant_tool_calls = [] + if hasattr(choice.message, "tool_calls") and choice.message.tool_calls: + for tc in choice.message.tool_calls: + if hasattr(tc, "function"): + assistant_tool_calls.append(f"{tc.function.name}({tc.function.arguments})") + + if assistant_content: + items.append(f"ASSISTANT: {assistant_content}") + if assistant_tool_calls: + items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(assistant_tool_calls)}") + + return "\n\n".join(items) + + +_storage_logger = logging.getLogger("hindsight_litellm.storage") + +# Track storage errors from background threads - raised on next completion call +_pending_storage_errors: List[Exception] = [] +_storage_error_lock = threading.Lock() + + +def _store_conversation_sync( + conversation_text: str, + bank_id: str, + document_id: Optional[str], + model: str, + verbose: bool, +) -> None: + """Actually store the conversation (runs in background thread).""" + global _pending_storage_errors + try: + retain( + content=conversation_text, + bank_id=bank_id, + context=f"conversation:litellm:{model}", + document_id=document_id, + metadata={"source": "litellm", "model": model}, + ) + if verbose: + _storage_logger.info(f"Stored conversation to bank: {bank_id}") + except Exception as e: + _storage_logger.error(f"Failed to store conversation: {e}") + # Store error to raise on next completion call + with _storage_error_lock: + _pending_storage_errors.append( + HindsightError(f"Background storage failed: {e}") + ) + + +def _check_pending_storage_errors() -> None: + """Check for and raise any pending storage errors from background threads.""" + global _pending_storage_errors + with _storage_error_lock: + if _pending_storage_errors: + # Get first error and clear the list + error = _pending_storage_errors[0] + _pending_storage_errors.clear() + raise error + + +def get_pending_storage_errors() -> List[Exception]: + """Get any pending storage errors without raising them. + + Useful for checking/logging errors without interrupting flow. + Clears the error queue after returning. + + Returns: + List of HindsightError exceptions from failed background storage operations + """ + global _pending_storage_errors + with _storage_error_lock: + errors = list(_pending_storage_errors) + _pending_storage_errors.clear() + return errors + + +def _store_conversation( + messages: List[dict], + response, + model: str, +) -> None: + """Store conversation to Hindsight. + + By default, storage runs in a background thread for performance. + If sync_storage=True in config, runs synchronously and raises errors. + Use get_pending_storage_errors() to check for async storage failures. + """ + config = get_config() + defaults = get_defaults() + + if not config or not config.store_conversations: + return + + if not defaults or not defaults.bank_id: + _storage_logger.warning( + "No bank_id configured for storage. Call set_defaults(bank_id=...)." + ) + return + + # Format conversation + conversation_text = _format_conversation_for_storage(messages, response) + + if not conversation_text: + return + + # Sync mode: run directly and raise errors + if config.sync_storage: + try: + retain( + content=conversation_text, + bank_id=defaults.bank_id, + context=f"conversation:litellm:{model}", + document_id=defaults.document_id, + metadata={"source": "litellm", "model": model}, + ) + if config.verbose: + _storage_logger.info(f"Stored conversation to bank: {defaults.bank_id}") + except Exception as e: + raise HindsightError(f"Failed to store conversation: {e}") from e + return + + # Async mode (default): run in background thread + thread = threading.Thread( + target=_store_conversation_sync, + args=( + conversation_text, + defaults.bank_id, + defaults.document_id, + model, + config.verbose, + ), + daemon=True, + ) + thread.start() + + def completion(*args, **kwargs): """Call LiteLLM completion with Hindsight memory integration. - This is a convenience wrapper that delegates to litellm.completion(). - Memory injection and storage happen automatically if configured and enabled. + This wrapper handles memory injection and storage explicitly, ensuring + that any Hindsight failures raise HindsightError instead of failing silently. Args: *args: Positional arguments passed to litellm.completion() **kwargs: Keyword arguments passed to litellm.completion() + Special hindsight_* kwargs: + - hindsight_query: Custom query for memory lookup (overrides user message) Returns: LiteLLM ModelResponse object + Raises: + HindsightError: If memory injection or storage fails + Example: >>> import hindsight_litellm >>> >>> hindsight_litellm.configure( ... hindsight_api_url="http://localhost:8888", - ... bank_id="my-agent", ... ) + >>> hindsight_litellm.set_defaults(bank_id="my-agent") >>> hindsight_litellm.enable() >>> >>> # Use directly - no need to import litellm separately @@ -634,31 +947,82 @@ def completion(*args, **kwargs): ... model="gpt-4o-mini", ... messages=[{"role": "user", "content": "Hello!"}] ... ) + >>> + >>> # With custom query for memory lookup + >>> response = hindsight_litellm.completion( + ... model="gpt-4o-mini", + ... messages=[{"role": "user", "content": "Please deliver package to Alice"}], + ... hindsight_query="Where is Alice located?", # Focused query for memory + ... ) + >>> + >>> # With custom reflect context (conversation history for reflect) + >>> response = hindsight_litellm.completion( + ... model="gpt-4o-mini", + ... messages=[...], + ... hindsight_query="What should I do next?", + ... hindsight_reflect_context="Step 1: Checked floor 1. Step 2: Found elevator.", + ... ) """ - return litellm.completion(*args, **kwargs) + config = get_config() + + # Extract hindsight-specific kwargs + custom_query = kwargs.pop("hindsight_query", None) + custom_reflect_context = kwargs.pop("hindsight_reflect_context", None) + + # Extract messages from kwargs or args + messages = kwargs.get("messages") + if messages is None and len(args) > 1: + messages = args[1] + + model = kwargs.get("model") + if model is None and len(args) > 0: + model = args[0] + + # Step 1: Inject memories (raises HindsightError on failure) + if config and config.inject_memories and messages: + try: + injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context) + kwargs["messages"] = injected_messages + except Exception as e: + raise HindsightError(f"Failed to inject memories: {e}") from e + + # Step 2: Call LLM + response = litellm.completion(*args, **kwargs) + + # Step 3: Store conversation (raises HindsightError on failure) + if config and config.store_conversations: + final_messages = kwargs.get("messages", messages) + _store_conversation(final_messages, response, model or "unknown") + + return response async def acompletion(*args, **kwargs): """Call LiteLLM async completion with Hindsight memory integration. - This is a convenience wrapper that delegates to litellm.acompletion(). - Memory injection and storage happen automatically if configured and enabled. + This wrapper handles memory injection and storage explicitly, ensuring + that any Hindsight failures raise HindsightError instead of failing silently. Args: *args: Positional arguments passed to litellm.acompletion() **kwargs: Keyword arguments passed to litellm.acompletion() + Special hindsight_* kwargs: + - hindsight_query: Custom query for memory lookup (overrides user message) Returns: LiteLLM ModelResponse object + Raises: + HindsightError: If memory injection or storage fails + Example: >>> import hindsight_litellm >>> import asyncio >>> >>> hindsight_litellm.configure( ... hindsight_api_url="http://localhost:8888", - ... bank_id="my-agent", ... ) + >>> hindsight_litellm.set_defaults(bank_id="my-agent") >>> hindsight_litellm.enable() >>> >>> async def main(): @@ -670,7 +1034,38 @@ async def acompletion(*args, **kwargs): >>> >>> asyncio.run(main()) """ - return await litellm.acompletion(*args, **kwargs) + config = get_config() + + # Extract hindsight-specific kwargs + custom_query = kwargs.pop("hindsight_query", None) + custom_reflect_context = kwargs.pop("hindsight_reflect_context", None) + + # Extract messages from kwargs or args + messages = kwargs.get("messages") + if messages is None and len(args) > 1: + messages = args[1] + + model = kwargs.get("model") + if model is None and len(args) > 0: + model = args[0] + + # Step 1: Inject memories (raises HindsightError on failure) + if config and config.inject_memories and messages: + try: + injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context) + kwargs["messages"] = injected_messages + except Exception as e: + raise HindsightError(f"Failed to inject memories: {e}") from e + + # Step 2: Call LLM + response = await litellm.acompletion(*args, **kwargs) + + # Step 3: Store conversation (raises HindsightError on failure) + if config and config.store_conversations: + final_messages = kwargs.get("messages", messages) + _store_conversation(final_messages, response, model or "unknown") + + return response @contextmanager @@ -683,13 +1078,13 @@ def hindsight_memory( injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE, max_memories: Optional[int] = None, max_memory_tokens: int = 4096, - recall_budget: str = "mid", + budget: str = "mid", fact_types: Optional[List[str]] = None, document_id: Optional[str] = None, excluded_models: Optional[List[str]] = None, verbose: bool = False, - bank_name: Optional[str] = None, - background: Optional[str] = None, + include_entities: bool = True, + trace: bool = False, ): """Context manager for temporary Hindsight memory integration. @@ -706,13 +1101,13 @@ def hindsight_memory( injection_mode: How to inject memories max_memories: Maximum number of memories to inject (None = unlimited) max_memory_tokens: Maximum tokens for memory context - recall_budget: Budget for memory recall (low, mid, high) - fact_types: List of fact types to filter (world, agent, opinion, observation) + budget: Budget for memory recall (low, mid, high) + fact_types: List of fact types to filter (world, experience, opinion, observation) document_id: Optional document ID for grouping conversations excluded_models: List of model patterns to exclude verbose: Enable verbose logging - bank_name: Optional display name for the memory bank - background: Optional background/instructions for memory extraction + include_entities: Include entity observations in recall (default True) + trace: Enable trace info for debugging (default False) Example: >>> from hindsight_litellm import hindsight_memory @@ -725,25 +1120,28 @@ def hindsight_memory( # Save previous state was_enabled = is_enabled() previous_config = get_config() + previous_defaults = get_defaults() try: # Configure and enable configure( hindsight_api_url=hindsight_api_url, - bank_id=bank_id, api_key=api_key, store_conversations=store_conversations, inject_memories=inject_memories, injection_mode=injection_mode, - max_memories=max_memories, - max_memory_tokens=max_memory_tokens, - recall_budget=recall_budget, - fact_types=fact_types, - document_id=document_id, excluded_models=excluded_models, verbose=verbose, - bank_name=bank_name, - background=background, + ) + set_defaults( + bank_id=bank_id, + document_id=document_id, + budget=budget, + fact_types=fact_types, + max_memories=max_memories, + max_memory_tokens=max_memory_tokens, + include_entities=include_entities, + trace=trace, ) enable() yield @@ -753,20 +1151,25 @@ def hindsight_memory( if previous_config: configure( hindsight_api_url=previous_config.hindsight_api_url, - bank_id=previous_config.bank_id, api_key=previous_config.api_key, store_conversations=previous_config.store_conversations, inject_memories=previous_config.inject_memories, injection_mode=previous_config.injection_mode, - max_memories=previous_config.max_memories, - max_memory_tokens=previous_config.max_memory_tokens, - recall_budget=previous_config.recall_budget, - fact_types=previous_config.fact_types, - document_id=previous_config.document_id, excluded_models=previous_config.excluded_models, verbose=previous_config.verbose, - bank_name=previous_config.bank_name, - background=previous_config.background, + ) + if previous_defaults: + set_defaults( + bank_id=previous_defaults.bank_id, + document_id=previous_defaults.document_id, + budget=previous_defaults.budget, + fact_types=previous_defaults.fact_types, + max_memories=previous_defaults.max_memories, + max_memory_tokens=previous_defaults.max_memory_tokens, + use_reflect=previous_defaults.use_reflect, + reflect_include_facts=previous_defaults.reflect_include_facts, + include_entities=previous_defaults.include_entities, + trace=previous_defaults.trace, ) if was_enabled: enable() @@ -777,6 +1180,7 @@ def hindsight_memory( __all__ = [ # Main API "configure", + "set_defaults", "enable", "disable", "is_enabled", @@ -802,16 +1206,25 @@ __all__ = [ "HindsightAnthropic", # Configuration "get_config", + "get_defaults", "is_configured", "reset_config", + "set_document_id", + "set_bank_mission", "HindsightConfig", + "HindsightDefaults", "MemoryInjectionMode", # Injection debug (verbose mode) "get_last_injection_debug", "clear_injection_debug", "InjectionDebugInfo", + # Storage errors (async mode) + "get_pending_storage_errors", + "get_pending_retain_errors", # Callback (for advanced usage) "HindsightCallback", "get_callback", "cleanup_callback", + # Exceptions + "HindsightError", ] diff --git a/hindsight-integrations/litellm/hindsight_litellm/callbacks.py b/hindsight-integrations/litellm/hindsight_litellm/callbacks.py index f6478593..3c9c248d 100644 --- a/hindsight-integrations/litellm/hindsight_litellm/callbacks.py +++ b/hindsight-integrations/litellm/hindsight_litellm/callbacks.py @@ -19,7 +19,14 @@ import concurrent.futures from litellm.integrations.custom_logger import CustomLogger from litellm.types.utils import ModelResponse -from .config import get_config, is_configured, HindsightConfig, MemoryInjectionMode +from .config import ( + get_config, + get_defaults, + is_configured, + HindsightConfig, + HindsightDefaults, + MemoryInjectionMode, +) # Use requests for sync HTTP calls to avoid async event loop issues try: @@ -37,6 +44,16 @@ except ImportError: logger = logging.getLogger(__name__) + +class HindsightError(Exception): + """Exception raised when a Hindsight operation fails. + + This is raised when inject_memories=True and recall fails, + or when store_conversations=True and store fails. + """ + pass + + # Thread pool for running async operations in background _executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="hindsight-") @@ -76,6 +93,43 @@ class HindsightCallback(CustomLogger): self._recent_hashes: Set[str] = set() self._max_hash_cache = 1000 + def _get_effective_settings(self, kwargs: Dict[str, Any]) -> HindsightDefaults: + """Get effective per-call settings from kwargs with fallback to defaults. + + Per-call kwargs (hindsight_*) override defaults. Supported kwargs: + - hindsight_bank_id: Override bank_id + - hindsight_document_id: Override document_id + - hindsight_budget: Override budget + - hindsight_fact_types: Override fact_types + - hindsight_max_memories: Override max_memories + - hindsight_max_memory_tokens: Override max_memory_tokens + - hindsight_use_reflect: Override use_reflect + - hindsight_reflect_include_facts: Override reflect_include_facts + - hindsight_context: Override reflect_context + - hindsight_response_schema: Override reflect_response_schema + - hindsight_include_entities: Override include_entities + - hindsight_trace: Override trace + + Note: hindsight_query is handled separately in log_pre_api_call since it's + always per-call (no sensible default for dynamic queries). + """ + defaults = get_defaults() or HindsightDefaults() + + return HindsightDefaults( + bank_id=kwargs.get("hindsight_bank_id", defaults.bank_id), + document_id=kwargs.get("hindsight_document_id", defaults.document_id), + budget=kwargs.get("hindsight_budget", defaults.budget), + fact_types=kwargs.get("hindsight_fact_types", defaults.fact_types), + max_memories=kwargs.get("hindsight_max_memories", defaults.max_memories), + max_memory_tokens=kwargs.get("hindsight_max_memory_tokens", defaults.max_memory_tokens), + use_reflect=kwargs.get("hindsight_use_reflect", defaults.use_reflect), + reflect_include_facts=kwargs.get("hindsight_reflect_include_facts", defaults.reflect_include_facts), + reflect_context=kwargs.get("hindsight_context", defaults.reflect_context), + reflect_response_schema=kwargs.get("hindsight_response_schema", defaults.reflect_response_schema), + include_entities=kwargs.get("hindsight_include_entities", defaults.include_entities), + trace=kwargs.get("hindsight_trace", defaults.trace), + ) + def _get_http_session(self): """Get or create a requests Session (thread-safe).""" if self._http_session is None: @@ -92,14 +146,18 @@ class HindsightCallback(CustomLogger): ) return self._http_session - def _http_post(self, url: str, json_data: dict, config: HindsightConfig) -> Optional[dict]: - """Make a synchronous HTTP POST request.""" - try: - session = self._get_http_session() - headers = {"Content-Type": "application/json"} - if config.api_key: - headers["Authorization"] = f"Bearer {config.api_key}" + def _http_post(self, url: str, json_data: dict, config: HindsightConfig) -> dict: + """Make a synchronous HTTP POST request. + Raises: + HindsightError: If the request fails for any reason. + """ + session = self._get_http_session() + headers = {"Content-Type": "application/json"} + if config.api_key: + headers["Authorization"] = f"Bearer {config.api_key}" + + try: if HAS_REQUESTS: response = session.post(url, json=json_data, headers=headers, timeout=30) response.raise_for_status() @@ -108,10 +166,14 @@ class HindsightCallback(CustomLogger): response = session.post(url, json=json_data, headers=headers) response.raise_for_status() return response.json() + else: + raise HindsightError("No HTTP client available (install requests or httpx)") + except HindsightError: + raise except Exception as e: if config.verbose: - logger.warning(f"HTTP POST failed: {e}") - return None + logger.error(f"HTTP POST failed: {e}") + raise HindsightError(f"Hindsight API request failed: {e}") from e def _should_skip_model(self, model: str, config: HindsightConfig) -> bool: """Check if this model should be excluded from interception.""" @@ -138,6 +200,20 @@ class HindsightCallback(CustomLogger): return " ".join(text_parts) return None + def _messages_to_query(self, messages: List[Dict[str, Any]]) -> str: + """Concatenate all message contents into a single query string.""" + message_parts = [] + for msg in messages: + content = msg.get("content", "") + if isinstance(content, str) and content: + message_parts.append(content) + elif isinstance(content, list): + # Handle structured content (e.g., vision messages) + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + message_parts.append(item.get("text", "")) + return "\n".join(message_parts) + def _compute_conversation_hash( self, user_input: str, @@ -163,6 +239,7 @@ class HindsightCallback(CustomLogger): def _format_memories( self, results: List[Any], + settings: HindsightDefaults, config: HindsightConfig ) -> str: """Format memory recall results into a context string. @@ -174,7 +251,7 @@ class HindsightCallback(CustomLogger): return "" # Apply limit if set, otherwise use all results - results_to_use = results[:config.max_memories] if config.max_memories else results + results_to_use = results[:settings.max_memories] if settings.max_memories else results memory_lines = [] for i, result in enumerate(results_to_use, 1): # Handle both RecallResult objects and dicts @@ -248,194 +325,343 @@ class HindsightCallback(CustomLogger): return updated_messages - def _get_bank_id(self, config: HindsightConfig) -> str: - """Get the bank_id for API calls.""" - return config.bank_id - def _recall_memories_sync( self, query: str, + settings: HindsightDefaults, config: HindsightConfig ) -> List[Dict[str, Any]]: - """Recall relevant memories from Hindsight (sync) using direct HTTP.""" + """Recall relevant memories from Hindsight (sync) using direct HTTP. + + Raises: + HindsightError: If inject_memories=True and recall fails. + """ + bank_id = settings.bank_id + if not bank_id: + raise HindsightError( + "No bank_id configured. Call set_defaults(bank_id=...) " + "or pass hindsight_bank_id=... to the completion call." + ) + + url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories/recall" + + request_data = { + "query": query, + "budget": settings.budget or "mid", + "max_tokens": settings.max_memory_tokens or 4096, + } + if settings.fact_types: + request_data["types"] = settings.fact_types + + # Add trace parameter for debugging + if settings.trace: + request_data["trace"] = True + + # Add include options for entity observations + # include_entities=True -> include: {entities: {}} + # include_entities=False -> include: {entities: null} + if settings.include_entities: + request_data["include"] = {"entities": {}} + else: + request_data["include"] = {"entities": None} + try: - bank_id = self._get_bank_id(config) - url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories/recall" - - request_data = { - "query": query, - "budget": config.recall_budget or "mid", - "max_tokens": config.max_memory_tokens or 4096, - } - if config.fact_types: - request_data["types"] = config.fact_types - response = self._http_post(url, request_data, config) if response and "results" in response: return response["results"] return [] - - except Exception as e: + except HindsightError as e: if config.verbose: - logger.warning(f"Failed to recall memories: {e}") - return [] + logger.error(f"Failed to recall memories: {e}") + raise HindsightError(f"Memory recall failed: {e}") from e async def _recall_memories_async( self, query: str, + settings: HindsightDefaults, config: HindsightConfig ) -> List[Any]: """Recall relevant memories from Hindsight (async). Uses thread pool executor with sync HTTP to avoid event loop conflicts. + + Raises: + HindsightError: If inject_memories=True and recall fails. """ - try: - loop = asyncio.get_running_loop() - results = await loop.run_in_executor( - _executor, - self._recall_memories_sync, - query, - config + loop = asyncio.get_running_loop() + results = await loop.run_in_executor( + _executor, + lambda: self._recall_memories_sync(query, settings, config) + ) + + return results if isinstance(results, list) else [] + + def _reflect_sync( + self, + query: str, + settings: HindsightDefaults, + config: HindsightConfig + ) -> Optional[str]: + """Generate a reflection response from Hindsight (sync) using direct HTTP. + + Returns: + The reflect response text, or None if no response. + + Raises: + HindsightError: If inject_memories=True and reflect fails. + """ + bank_id = settings.bank_id + if not bank_id: + raise HindsightError( + "No bank_id configured. Call set_defaults(bank_id=...) " + "or pass hindsight_bank_id=... to the completion call." ) - return results if isinstance(results, list) else [] + url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/reflect" - except Exception as e: + request_data: Dict[str, Any] = { + "query": query, + "budget": settings.budget or "mid", + "max_tokens": settings.max_memory_tokens or 4096, + } + + # Add context if provided (shapes reasoning but not retrieval) + if settings.reflect_context: + request_data["context"] = settings.reflect_context + + # Add response_schema for structured output + if settings.reflect_response_schema: + request_data["response_schema"] = settings.reflect_response_schema + + # Add include options for facts if requested + if settings.reflect_include_facts: + request_data["include"] = {"facts": {}} + + try: + response = self._http_post(url, request_data, config) + if response: + # Handle structured output if schema was provided + if settings.reflect_response_schema and "structured_output" in response: + # Return structured output as JSON string for injection + import json + return json.dumps(response["structured_output"], indent=2) + # Otherwise return text response + return response.get("text", "") + return None + except HindsightError as e: if config.verbose: - logger.warning(f"Failed to recall memories: {e}") - return [] + logger.error(f"Failed to reflect: {e}") + raise HindsightError(f"Reflect failed: {e}") from e + + async def _reflect_async( + self, + query: str, + settings: HindsightDefaults, + config: HindsightConfig + ) -> Optional[str]: + """Generate a reflection response from Hindsight (async). + + Uses thread pool executor with sync HTTP to avoid event loop conflicts. + + Returns: + The reflect response text, or None if no response. + + Raises: + HindsightError: If inject_memories=True and reflect fails. + """ + loop = asyncio.get_running_loop() + result = await loop.run_in_executor( + _executor, + lambda: self._reflect_sync(query, settings, config) + ) + return result def _store_conversation_sync( self, messages: List[Dict[str, Any]], response: ModelResponse, model: str, + settings: HindsightDefaults, config: HindsightConfig, ) -> None: """Store the conversation to Hindsight (sync) using direct HTTP. - By default, stores the full conversation history passed to the LLM. - Each message is stored as a separate item, all linked by document_id. + IMPORTANT: This intentionally sends the FULL conversation history each call, + not just the new messages. This is required because Hindsight's retain API + with document_id performs an UPSERT (replace), not an append. - Hindsight will process the document as a whole for memory extraction. + If we only sent deltas (new messages), Hindsight would only have the latest + fragment and lose all prior context. By sending the full conversation each + time, Hindsight always has the complete context to extract meaningful facts. + + Example with delta-only (WRONG): + Call 1: "USER: deliver to Alex\\nASSISTANT_TOOL_CALLS: look_at_business" + Call 2: "TOOL_RESULT: TechStart Labs\\nASSISTANT_TOOL_CALLS: go_up" # Lost context! + + Example with full conversation (CORRECT): + Call 1: "USER: deliver to Alex\\nASSISTANT_TOOL_CALLS: look_at_business" + Call 2: "USER: deliver to Alex\\nASSISTANT_TOOL_CALLS: look_at_business\\n + TOOL_RESULT: TechStart Labs\\nASSISTANT_TOOL_CALLS: go_up" # Full context! + + Each upsert replaces the previous, so the final stored document contains + the complete conversation for Hindsight to process. + + Raises: + HindsightError: If store_conversations=True and store fails. """ - try: - # Extract assistant response from the LLM response - assistant_output = "" - if response.choices and len(response.choices) > 0: - choice = response.choices[0] - if hasattr(choice, "message") and choice.message: - assistant_output = choice.message.content or "" + bank_id = settings.bank_id + if not bank_id: + raise HindsightError( + "No bank_id configured. Call set_defaults(bank_id=...) " + "or pass hindsight_bank_id=... to the completion call." + ) - if not assistant_output: - return + # Extract assistant response from the LLM response + assistant_output = "" + assistant_tool_calls = [] + if response.choices and len(response.choices) > 0: + choice = response.choices[0] + if hasattr(choice, "message") and choice.message: + assistant_output = choice.message.content or "" + # Also capture tool calls + if hasattr(choice.message, "tool_calls") and choice.message.tool_calls: + for tc in choice.message.tool_calls: + if hasattr(tc, "function"): + assistant_tool_calls.append(f"{tc.function.name}({tc.function.arguments})") - # Build conversation items - each message becomes a separate item - # All linked by document_id for Hindsight to process together - items = [] - for msg in messages: - role = msg.get("role", "").upper() - content = msg.get("content", "") + # Skip if no content AND no tool calls - nothing to store + if not assistant_output and not assistant_tool_calls: + return - # Skip system messages - they're instructions, not conversation - if role == "SYSTEM": - continue + # Build conversation items - each message becomes a separate item + # All linked by document_id for Hindsight to process together + items = [] + for msg in messages: + role = msg.get("role", "").upper() + content = msg.get("content", "") - # Skip if this looks like our injected memory context - if isinstance(content, str) and content.startswith("# Relevant Memories"): - continue + # Skip system messages - they're instructions, not conversation + if role == "SYSTEM": + continue - # Handle structured content (e.g., vision messages) - if isinstance(content, list): - text_parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - text_parts.append(item.get("text", "")) - content = " ".join(text_parts) + # Skip if this looks like our injected memory context + if isinstance(content, str) and content.startswith("# Relevant Memories"): + continue + # Handle tool messages (results from tool calls) + if role == "TOOL": + tool_call_id = msg.get("tool_call_id", "") + items.append(f"TOOL_RESULT: {content}") + continue + + # Handle assistant messages with tool calls + tool_calls = msg.get("tool_calls", []) + if tool_calls: + tc_strs = [] + for tc in tool_calls: + if hasattr(tc, "function"): + tc_strs.append(f"{tc.function.name}({tc.function.arguments})") + elif isinstance(tc, dict) and "function" in tc: + func = tc["function"] + tc_strs.append(f"{func.get('name', '')}({func.get('arguments', '')})") + if tc_strs: + items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(tc_strs)}") if content: - # Map roles to clearer labels - label = "USER" if role == "USER" else "ASSISTANT" - items.append(f"{label}: {content}") + items.append(f"ASSISTANT: {content}") + continue - # Add the new assistant response + # Handle structured content (e.g., vision messages) + if isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + content = " ".join(text_parts) + + if content: + # Map roles to clearer labels + label = "USER" if role == "USER" else "ASSISTANT" + items.append(f"{label}: {content}") + + # Add the new assistant response (text or tool calls) + if assistant_output: items.append(f"ASSISTANT: {assistant_output}") + if assistant_tool_calls: + items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(assistant_tool_calls)}") - if not items: - return + if not items: + return - # Use last user message for deduplication hash - user_input = self._extract_user_query(messages) or "" + # Use last user message for deduplication hash + user_input = self._extract_user_query(messages) or "" - # Deduplication check - conv_hash = self._compute_conversation_hash(user_input, assistant_output) - if self._is_duplicate(conv_hash): - if config.verbose: - logger.debug(f"Skipping duplicate conversation: {conv_hash}") - return + # Deduplication check - include tool calls if no text content + dedup_output = assistant_output or ";".join(assistant_tool_calls) + conv_hash = self._compute_conversation_hash(user_input, dedup_output) + if self._is_duplicate(conv_hash): + if config.verbose: + logger.debug(f"Skipping duplicate conversation: {conv_hash}") + return - # Build the full conversation as a single item for now - # (Future: could store each message as separate item in same document) - conversation_text = "\n\n".join(items) + # Build the full conversation as a single item for now + # (Future: could store each message as separate item in same document) + conversation_text = "\n\n".join(items) - # Build metadata - metadata = { - "source": "litellm", - "model": model, - } + # Build metadata + metadata = { + "source": "litellm", + "model": model, + } - # Add token usage if available - if hasattr(response, "usage") and response.usage: - if hasattr(response.usage, "total_tokens"): - metadata["tokens"] = str(response.usage.total_tokens) + # Add token usage if available + if hasattr(response, "usage") and response.usage: + if hasattr(response.usage, "total_tokens"): + metadata["tokens"] = str(response.usage.total_tokens) - bank_id = self._get_bank_id(config) - url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories" + url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories" - request_data = { - "items": [ - { - "content": conversation_text, - "context": f"conversation:litellm:{model}", - "metadata": metadata, - "document_id": config.document_id, # Group by document - } - ], - } + request_data = { + "items": [ + { + "content": conversation_text, + "context": f"conversation:litellm:{model}", + "metadata": metadata, + "document_id": settings.document_id, # Group by document + } + ], + } + try: self._http_post(url, request_data, config) - if config.verbose: - logger.info(f"Stored conversation to Hindsight bank: {config.bank_id}") - - except Exception as e: + logger.info(f"Stored conversation to Hindsight bank: {bank_id}") + except HindsightError as e: if config.verbose: - logger.warning(f"Failed to store conversation: {e}") + logger.error(f"Failed to store conversation: {e}") + raise HindsightError(f"Memory storage failed: {e}") from e async def _store_conversation_async( self, messages: List[Dict[str, Any]], response: ModelResponse, model: str, + settings: HindsightDefaults, config: HindsightConfig, ) -> None: """Store the conversation to Hindsight (async). Uses thread pool executor with sync HTTP to avoid event loop conflicts. + + Raises: + HindsightError: If store_conversations=True and store fails. """ - try: - loop = asyncio.get_running_loop() - await loop.run_in_executor( - _executor, - self._store_conversation_sync, - messages, - response, - model, - config + loop = asyncio.get_running_loop() + await loop.run_in_executor( + _executor, + lambda: self._store_conversation_sync( + messages, response, model, settings, config ) - except Exception as e: - if config.verbose: - logger.warning(f"Failed to store conversation: {e}") + ) # ========== LiteLLM CustomLogger Interface ========== @@ -449,28 +675,53 @@ class HindsightCallback(CustomLogger): This is where we inject memories into the messages. """ - if not is_configured(): + config = get_config() + if not config or not config.inject_memories: return - config = get_config() - if not config or not config.enabled or not config.inject_memories: - return + # Get effective settings (kwargs override defaults) + settings = self._get_effective_settings(kwargs) + if not settings.bank_id: + raise ValueError( + "No bank_id configured. Either call set_defaults(bank_id=...) " + "or pass hindsight_bank_id=... to the completion call." + ) if self._should_skip_model(model, config): return - # Extract user query - user_query = self._extract_user_query(messages) - if not user_query: - return + # hindsight_query is required when inject_memories=True + custom_query = kwargs.get("hindsight_query") + if not custom_query: + raise ValueError( + "hindsight_query is required when inject_memories=True. " + "Pass hindsight_query='your query' to specify what to search for in memory. " + "Example: hindsight_query=recipient_name or hindsight_query='What do I know about Alice?'" + ) - # Recall relevant memories - memories = self._recall_memories_sync(user_query, config) - if not memories: - return + user_query = custom_query + + # Use reflect or recall based on settings + if settings.use_reflect: + # Use reflect API for disposition-aware reasoning + reflect_response = self._reflect_sync(user_query, settings, config) + if not reflect_response: + return + + # Format reflect response as context + memory_context = ( + "# Relevant Context from Memory\n" + f"{reflect_response}" + ) + else: + # Use recall API for raw fact retrieval + memories = self._recall_memories_sync(user_query, settings, config) + if not memories: + return + + # Format and inject memories + memory_context = self._format_memories(memories, settings, config) - # Format and inject memories - memory_context = self._format_memories(memories, config) updated_messages = self._inject_memories_into_messages( messages, memory_context, config ) @@ -480,7 +731,8 @@ class HindsightCallback(CustomLogger): messages.extend(updated_messages) if config.verbose: - logger.info(f"Injected {len(memories)} memories into prompt") + mode = "reflect" if settings.use_reflect else "recall" + logger.info(f"Injected memory context via {mode}") async def async_log_pre_api_call( self, @@ -492,28 +744,53 @@ class HindsightCallback(CustomLogger): This is where we inject memories into the messages. """ - if not is_configured(): + config = get_config() + if not config or not config.inject_memories: return - config = get_config() - if not config or not config.enabled or not config.inject_memories: - return + # Get effective settings (kwargs override defaults) + settings = self._get_effective_settings(kwargs) + if not settings.bank_id: + raise ValueError( + "No bank_id configured. Either call set_defaults(bank_id=...) " + "or pass hindsight_bank_id=... to the completion call." + ) if self._should_skip_model(model, config): return - # Extract user query - user_query = self._extract_user_query(messages) - if not user_query: - return + # hindsight_query is required when inject_memories=True + custom_query = kwargs.get("hindsight_query") + if not custom_query: + raise ValueError( + "hindsight_query is required when inject_memories=True. " + "Pass hindsight_query='your query' to specify what to search for in memory. " + "Example: hindsight_query=recipient_name or hindsight_query='What do I know about Alice?'" + ) - # Recall relevant memories - memories = await self._recall_memories_async(user_query, config) - if not memories: - return + user_query = custom_query + + # Use reflect or recall based on settings + if settings.use_reflect: + # Use reflect API for disposition-aware reasoning + reflect_response = await self._reflect_async(user_query, settings, config) + if not reflect_response: + return + + # Format reflect response as context + memory_context = ( + "# Relevant Context from Memory\n" + f"{reflect_response}" + ) + else: + # Use recall API for raw fact retrieval + memories = await self._recall_memories_async(user_query, settings, config) + if not memories: + return + + # Format and inject memories + memory_context = self._format_memories(memories, settings, config) - # Format and inject memories - memory_context = self._format_memories(memories, config) updated_messages = self._inject_memories_into_messages( messages, memory_context, config ) @@ -523,7 +800,8 @@ class HindsightCallback(CustomLogger): messages.extend(updated_messages) if config.verbose: - logger.info(f"Injected {len(memories)} memories into prompt") + mode = "reflect" if settings.use_reflect else "recall" + logger.info(f"Injected memory context via {mode}") def log_success_event( self, @@ -536,11 +814,14 @@ class HindsightCallback(CustomLogger): This is where we store the conversation. """ - if not is_configured(): + config = get_config() + if not config or not config.store_conversations: return - config = get_config() - if not config or not config.enabled or not config.store_conversations: + # Get effective settings (kwargs override defaults) + settings = self._get_effective_settings(kwargs) + if not settings.bank_id: + # bank_id validation already done in log_pre_api_call return model = kwargs.get("model", "unknown") @@ -552,7 +833,7 @@ class HindsightCallback(CustomLogger): return # Store the conversation - self._store_conversation_sync(messages, response_obj, model, config) + self._store_conversation_sync(messages, response_obj, model, settings, config) async def async_log_success_event( self, @@ -565,11 +846,14 @@ class HindsightCallback(CustomLogger): This is where we store the conversation. """ - if not is_configured(): + config = get_config() + if not config or not config.store_conversations: return - config = get_config() - if not config or not config.enabled or not config.store_conversations: + # Get effective settings (kwargs override defaults) + settings = self._get_effective_settings(kwargs) + if not settings.bank_id: + # bank_id validation already done in async_log_pre_api_call return model = kwargs.get("model", "unknown") @@ -581,7 +865,7 @@ class HindsightCallback(CustomLogger): return # Store the conversation - await self._store_conversation_async(messages, response_obj, model, config) + await self._store_conversation_async(messages, response_obj, model, settings, config) def log_failure_event( self, diff --git a/hindsight-integrations/litellm/hindsight_litellm/config.py b/hindsight-integrations/litellm/hindsight_litellm/config.py index 06673977..cf2b126f 100644 --- a/hindsight-integrations/litellm/hindsight_litellm/config.py +++ b/hindsight-integrations/litellm/hindsight_litellm/config.py @@ -1,179 +1,258 @@ -"""Global configuration for Hindsight-LiteLLM integration.""" +"""Global configuration for Hindsight-LiteLLM integration. -from typing import Optional, List +This module provides a clean API for configuring Hindsight integration: + +1. configure() - Static settings that rarely change during a session + - API URL, authentication, logging, injection mode, etc. + +2. set_defaults() - Default values for per-call settings + - bank_id, document_id, budget, fact_types, etc. + - These are used when per-call kwargs are not provided + +3. Per-call kwargs (hindsight_* prefix) - Override any default per-call + - hindsight_bank_id, hindsight_document_id, etc. + +4. set_bank_mission() - Set the mission for a memory bank (for mental models) +""" + +from typing import Optional, List, Any, Dict from dataclasses import dataclass, field from enum import Enum class MemoryInjectionMode(str, Enum): - """How memories should be injected into the prompt.""" - SYSTEM_MESSAGE = "system_message" # Add as system message - PREPEND_USER = "prepend_user" # Prepend to user message - DISABLED = "disabled" # Don't inject memories + """How memories should be injected into the prompt. + + Use inject_memories=False if you don't want memory injection. + """ + SYSTEM_MESSAGE = "system_message" # Add to/create system message + PREPEND_USER = "prepend_user" # Prepend to last user message @dataclass class HindsightConfig: - """Configuration for Hindsight integration with LiteLLM. + """Static configuration for Hindsight integration with LiteLLM. + + These settings typically don't change during a session. Attributes: hindsight_api_url: URL of the Hindsight API server - bank_id: Memory bank ID for memory operations (required). For multi-user - support, use different bank_ids per user (e.g., f"user-{user_id}") api_key: Optional API key for Hindsight authentication store_conversations: Whether to store conversations to Hindsight inject_memories: Whether to inject relevant memories into prompts injection_mode: How to inject memories (system_message or prepend_user) - max_memories: Maximum number of memories to inject - max_memory_tokens: Maximum tokens for injected memory context - recall_budget: Budget level for memory recall (low, mid, high) - fact_types: List of fact types to filter recall (world, agent, opinion, observation) - document_id: Optional document ID for grouping stored conversations - enabled: Master switch to enable/disable Hindsight integration excluded_models: List of model patterns to exclude from interception verbose: Enable verbose logging - bank_name: Optional display name for the memory bank - background: Optional background/instructions for memory extraction - use_reflect: Use reflect API instead of recall for memory injection (synthesizes answer) + sync_storage: If True, storage runs synchronously and raises errors immediately. + If False (default), storage runs in background thread for better performance. """ hindsight_api_url: str = "http://localhost:8888" - bank_id: Optional[str] = None api_key: Optional[str] = None store_conversations: bool = True inject_memories: bool = True injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE - max_memories: Optional[int] = None # None = no limit (use all results from API) - max_memory_tokens: int = 4096 - recall_budget: str = "mid" # low, mid, high - fact_types: Optional[List[str]] = None # world, agent, opinion, observation - document_id: Optional[str] = None - enabled: bool = True excluded_models: List[str] = field(default_factory=list) verbose: bool = False - bank_name: Optional[str] = None # Display name for the memory bank - background: Optional[str] = None # Background/instructions for memory extraction - use_reflect: bool = False # Use reflect instead of recall for memory injection - reflect_include_facts: bool = False # Include facts used by reflect in debug info + sync_storage: bool = False -# Global configuration instance +@dataclass +class HindsightDefaults: + """Default values for per-call settings. + + These can be overridden on a per-call basis using hindsight_* kwargs. + + Attributes: + bank_id: Memory bank ID for memory operations + document_id: Optional document ID for grouping stored conversations + budget: Budget level for memory recall (low, mid, high) + fact_types: List of fact types to filter recall (world, experience, opinion, observation) + max_memories: Maximum number of memories to inject (None = no limit) + max_memory_tokens: Maximum tokens for injected memory context + use_reflect: Use reflect API instead of recall for memory injection + reflect_include_facts: Include facts used by reflect in debug info + reflect_context: Additional context for reflect reasoning (does not affect retrieval) + reflect_response_schema: JSON Schema for structured reflect output + include_entities: Include entity observations in recall results + trace: Enable trace info for recall debugging + + Note: + For custom queries, use the hindsight_query kwarg per-call instead of a default, + since queries typically need to be dynamic (e.g., include recipient name). + """ + + bank_id: Optional[str] = None + document_id: Optional[str] = None + budget: str = "mid" # low, mid, high + fact_types: Optional[List[str]] = None # world, experience, opinion, observation + max_memories: Optional[int] = None # None = no limit + max_memory_tokens: int = 4096 + use_reflect: bool = False + reflect_include_facts: bool = False + reflect_context: Optional[str] = None # Context for reflect reasoning + reflect_response_schema: Optional[Dict[str, Any]] = None # JSON Schema for structured output + include_entities: bool = True # Include entity observations by default + trace: bool = False # Enable trace info for debugging + + +# Global instances _global_config: Optional[HindsightConfig] = None +_global_defaults: Optional[HindsightDefaults] = None def configure( hindsight_api_url: str = "http://localhost:8888", - bank_id: Optional[str] = None, api_key: Optional[str] = None, store_conversations: bool = True, inject_memories: bool = True, injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE, - max_memories: Optional[int] = None, - max_memory_tokens: int = 4096, - recall_budget: str = "mid", - fact_types: Optional[List[str]] = None, - document_id: Optional[str] = None, - enabled: bool = True, excluded_models: Optional[List[str]] = None, verbose: bool = False, - bank_name: Optional[str] = None, - background: Optional[str] = None, - use_reflect: bool = False, - reflect_include_facts: bool = False, + sync_storage: bool = False, ) -> HindsightConfig: - """Configure global Hindsight integration settings for LiteLLM. + """Configure static Hindsight integration settings for LiteLLM. - This function sets up the global configuration that will be used by the - LiteLLM callbacks to inject memories and store conversations. + This sets up settings that typically don't change during a session. + For per-call settings like bank_id, use set_defaults() or per-call kwargs. Args: hindsight_api_url: URL of the Hindsight API server - bank_id: Memory bank ID for memory operations (required). For multi-user - support, use different bank_ids per user (e.g., f"user-{user_id}") api_key: Optional API key for Hindsight authentication store_conversations: Whether to store conversations to Hindsight inject_memories: Whether to inject relevant memories into prompts injection_mode: How to inject memories into the prompt - max_memories: Maximum number of memories to inject - max_memory_tokens: Maximum tokens for injected memory context - recall_budget: Budget level for memory recall (low, mid, high) - fact_types: List of fact types to filter (world, agent, opinion, observation) - document_id: Optional document ID for grouping stored conversations - enabled: Master switch to enable/disable Hindsight integration excluded_models: List of model patterns to exclude from interception verbose: Enable verbose logging - bank_name: Optional display name for the memory bank - background: Optional background/instructions that help Hindsight understand - what information is important to extract and remember from conversations. - This is passed to create_bank() to configure the memory bank. - use_reflect: Use reflect API instead of recall for memory injection. - When True, Hindsight will synthesize a contextual answer based on - memories rather than returning raw memory facts. - reflect_include_facts: When use_reflect=True, include the facts that - were used to generate the reflect response in the debug info. - This is useful for debugging what memories the reflect API used. + sync_storage: If True, storage runs synchronously and raises errors immediately. + If False (default), storage runs in background for better performance. + Use get_pending_storage_errors() to check for async storage failures. Returns: The configured HindsightConfig instance Example: - >>> from hindsight_litellm import configure, enable + >>> from hindsight_litellm import configure, set_defaults, enable >>> configure( ... hindsight_api_url="http://localhost:8888", - ... bank_id="user-123", # Per-user bank for multi-user support - ... store_conversations=True, - ... inject_memories=True, - ... background="This agent routes customer requests to support channels. " - ... "Remember which types of issues should go to which channels.", + ... api_key="your-api-key", + ... verbose=True, ... ) - >>> enable() # Register callbacks with LiteLLM + >>> set_defaults(bank_id="user-123") + >>> enable() # Start memory integration """ global _global_config _global_config = HindsightConfig( hindsight_api_url=hindsight_api_url, - bank_id=bank_id, api_key=api_key, store_conversations=store_conversations, inject_memories=inject_memories, injection_mode=injection_mode, - max_memories=max_memories, - max_memory_tokens=max_memory_tokens, - recall_budget=recall_budget, - fact_types=fact_types, - document_id=document_id, - enabled=enabled, excluded_models=excluded_models or [], verbose=verbose, - bank_name=bank_name, - background=background, - use_reflect=use_reflect, - reflect_include_facts=reflect_include_facts, + sync_storage=sync_storage, ) - # If background or bank_name is provided, create/update the bank - if bank_id and (background or bank_name): - _create_or_update_bank( - hindsight_api_url=hindsight_api_url, - bank_id=bank_id, - name=bank_name, - background=background, - verbose=verbose, - ) - return _global_config +def set_defaults( + bank_id: Optional[str] = None, + document_id: Optional[str] = None, + budget: Optional[str] = None, + fact_types: Optional[List[str]] = None, + max_memories: Optional[int] = None, + max_memory_tokens: Optional[int] = None, + use_reflect: Optional[bool] = None, + reflect_include_facts: Optional[bool] = None, + reflect_context: Optional[str] = None, + reflect_response_schema: Optional[Dict[str, Any]] = None, + include_entities: Optional[bool] = None, + trace: Optional[bool] = None, +) -> HindsightDefaults: + """Set default values for per-call settings. + + These defaults are used when per-call kwargs are not provided. + Any of these can be overridden on individual LLM calls using + hindsight_* kwargs (e.g., hindsight_bank_id="other-bank"). + + Args: + bank_id: Default memory bank ID for memory operations + document_id: Default document ID for grouping stored conversations + budget: Default budget level for memory recall (low, mid, high) + fact_types: Default fact types to filter (world, experience, opinion, observation) + max_memories: Default max number of memories to inject + max_memory_tokens: Default max tokens for memory context + use_reflect: Default whether to use reflect API instead of recall + reflect_include_facts: Default whether to include facts in reflect debug info + reflect_context: Default context for reflect reasoning (shapes LLM response, not retrieval) + reflect_response_schema: Default JSON Schema for structured reflect output + include_entities: Default whether to include entity observations in recall (default True) + trace: Default whether to enable trace info for debugging (default False) + + Returns: + The configured HindsightDefaults instance + + Note: + For custom memory queries, use hindsight_query per-call instead of a default, + since queries typically need to be dynamic (e.g., include recipient name). + + Example: + >>> from hindsight_litellm import set_defaults + >>> set_defaults( + ... bank_id="my-agent", + ... budget="high", + ... fact_types=["world", "opinion"], + ... reflect_context="I am a delivery agent finding package recipients.", + ... ) + >>> + >>> # Override per-call with dynamic query: + >>> response = litellm.completion( + ... model="gpt-4", + ... messages=[...], + ... hindsight_query=f"Where is {recipient_name} located?", # Dynamic query + ... ) + """ + global _global_defaults + + # Get current defaults or create new + current = _global_defaults or HindsightDefaults() + + # Update only provided values + _global_defaults = HindsightDefaults( + bank_id=bank_id if bank_id is not None else current.bank_id, + document_id=document_id if document_id is not None else current.document_id, + budget=budget if budget is not None else current.budget, + fact_types=fact_types if fact_types is not None else current.fact_types, + max_memories=max_memories if max_memories is not None else current.max_memories, + max_memory_tokens=max_memory_tokens if max_memory_tokens is not None else current.max_memory_tokens, + use_reflect=use_reflect if use_reflect is not None else current.use_reflect, + reflect_include_facts=reflect_include_facts if reflect_include_facts is not None else current.reflect_include_facts, + reflect_context=reflect_context if reflect_context is not None else current.reflect_context, + reflect_response_schema=reflect_response_schema if reflect_response_schema is not None else current.reflect_response_schema, + include_entities=include_entities if include_entities is not None else current.include_entities, + trace=trace if trace is not None else current.trace, + ) + + return _global_defaults + + def _create_or_update_bank( hindsight_api_url: str, bank_id: str, name: Optional[str] = None, - background: Optional[str] = None, + mission: Optional[str] = None, verbose: bool = False, ) -> None: """Create or update a memory bank with the given configuration. - This is called automatically by configure() when background or bank_name is provided. + Args: + hindsight_api_url: URL of the Hindsight API server + bank_id: The bank ID to create/update + name: Optional display name for the bank + mission: Instructions guiding what Hindsight should learn and remember + verbose: Enable verbose logging """ try: from hindsight_client import Hindsight @@ -182,18 +261,18 @@ def _create_or_update_bank( client.create_bank( bank_id=bank_id, name=name, - background=background, + mission=mission, ) if verbose: import logging logging.getLogger("hindsight_litellm").info( - f"Created/updated bank '{bank_id}' with background" + f"Created/updated bank '{bank_id}' with mission" ) except ImportError: if verbose: import logging logging.getLogger("hindsight_litellm").warning( - "hindsight_client not installed. Cannot create bank with background. " + "hindsight_client not installed. Cannot create bank. " "Install with: pip install hindsight-client" ) except Exception as e: @@ -205,7 +284,7 @@ def _create_or_update_bank( def get_config() -> Optional[HindsightConfig]: - """Get the current global configuration. + """Get the current global static configuration. Returns: The current HindsightConfig instance, or None if not configured @@ -213,20 +292,136 @@ def get_config() -> Optional[HindsightConfig]: return _global_config -def is_configured() -> bool: - """Check if Hindsight has been configured. +def get_defaults() -> Optional[HindsightDefaults]: + """Get the current global defaults for per-call settings. Returns: - True if configure() has been called with a valid bank_id + The current HindsightDefaults instance, or None if not set + """ + return _global_defaults + + +def is_configured() -> bool: + """Check if Hindsight has been configured with a valid bank_id. + + Returns: + True if configure() has been called and a bank_id is set in defaults """ return ( _global_config is not None - and _global_config.enabled - and _global_config.bank_id is not None + and _global_defaults is not None + and _global_defaults.bank_id is not None ) def reset_config() -> None: - """Reset the global configuration to None.""" - global _global_config + """Reset all global configuration to None.""" + global _global_config, _global_defaults _global_config = None + _global_defaults = None + + +def set_document_id(document_id: str | None) -> None: + """Set the document_id for grouping stored conversations. + + This is a convenience function that updates just the document_id + in the defaults without requiring a full set_defaults() call. + + When document_id is set, Hindsight uses upsert behavior: + - Same document_id = replace previous version + - Hindsight deduplicates facts automatically + + Args: + document_id: Document ID for grouping conversations, or None to clear + + Example: + >>> from hindsight_litellm import configure, set_defaults, enable, set_document_id + >>> configure(hindsight_api_url="http://localhost:8888") + >>> set_defaults(bank_id="my-agent") + >>> enable() + >>> + >>> # Start a new conversation + >>> set_document_id("conversation-123") + >>> response = litellm.completion(model="gpt-4", messages=[...]) + >>> + >>> # Switch to another conversation + >>> set_document_id("conversation-456") + >>> response = litellm.completion(model="gpt-4", messages=[...]) + """ + global _global_defaults + if _global_defaults is not None: + _global_defaults = HindsightDefaults( + bank_id=_global_defaults.bank_id, + document_id=document_id, + budget=_global_defaults.budget, + fact_types=_global_defaults.fact_types, + max_memories=_global_defaults.max_memories, + max_memory_tokens=_global_defaults.max_memory_tokens, + use_reflect=_global_defaults.use_reflect, + reflect_include_facts=_global_defaults.reflect_include_facts, + reflect_context=_global_defaults.reflect_context, + reflect_response_schema=_global_defaults.reflect_response_schema, + include_entities=_global_defaults.include_entities, + trace=_global_defaults.trace, + ) + else: + # Create defaults with just document_id if none exist + _global_defaults = HindsightDefaults(document_id=document_id) + + +def set_bank_mission( + bank_id: Optional[str] = None, + mission: Optional[str] = None, + name: Optional[str] = None, +) -> None: + """Set or update the mission for a memory bank. + + The mission guides Hindsight on what information to learn and remember, + and is used for mental model generation. If the bank doesn't exist, + it will be auto-created. + + Args: + bank_id: The bank ID to update. If not provided, uses the default bank_id. + mission: Instructions guiding what Hindsight should learn and remember. + name: Optional display name for the bank. + + Raises: + ValueError: If no bank_id is provided and no default is set. + RuntimeError: If configure() hasn't been called. + + Example: + >>> from hindsight_litellm import configure, set_defaults, set_bank_mission + >>> configure(hindsight_api_url="http://localhost:8888") + >>> set_defaults(bank_id="delivery-agent") + >>> set_bank_mission( + ... mission="You are a delivery agent navigating a building. " + ... "Remember employee locations, building layout, and optimal paths." + ... ) + """ + config = get_config() + if not config: + raise RuntimeError("Hindsight not configured. Call configure() first.") + + # Determine which bank_id to use + effective_bank_id = bank_id + if effective_bank_id is None: + defaults = get_defaults() + if defaults: + effective_bank_id = defaults.bank_id + + if not effective_bank_id: + raise ValueError( + "No bank_id provided and no default bank_id set. " + "Either pass bank_id or call set_defaults(bank_id=...) first." + ) + + # Use the Hindsight API to create/update the bank + _create_or_update_bank( + hindsight_api_url=config.hindsight_api_url, + bank_id=effective_bank_id, + name=name, + mission=mission, + verbose=config.verbose, + ) + + diff --git a/hindsight-integrations/litellm/hindsight_litellm/wrappers.py b/hindsight-integrations/litellm/hindsight_litellm/wrappers.py index 9b5feecc..e0ef2502 100644 --- a/hindsight-integrations/litellm/hindsight_litellm/wrappers.py +++ b/hindsight-integrations/litellm/hindsight_litellm/wrappers.py @@ -8,15 +8,36 @@ integration with native client libraries. """ import logging +import threading from typing import Any, Dict, List, Optional, Union from dataclasses import dataclass -from .config import get_config, is_configured, HindsightConfig +from .config import get_config, get_defaults, is_configured, HindsightConfig +# Background thread support for async retain +_retain_errors: List[Exception] = [] +_retain_errors_lock = threading.Lock() + logger = logging.getLogger(__name__) +def _get_client(api_url: str): + """Create a fresh Hindsight client for the given URL. + + Note: We create a fresh client each time because the hindsight_client + uses aiohttp internally, and reusing clients across different sync + calls causes asyncio context issues. + """ + from hindsight_client import Hindsight + return Hindsight(base_url=api_url, timeout=30.0) + + +def _close_client(): + """No-op for compatibility. Clients are now closed after each use.""" + pass + + @dataclass class RecallResult: """A single memory recall result.""" @@ -106,24 +127,25 @@ def recall( >>> if memories.debug: ... print(f"Queried bank: {memories.debug.bank_id}") """ - # Get config or use overrides + # Get config and defaults, or use overrides config = get_config() + defaults = get_defaults() api_url = hindsight_api_url or (config.hindsight_api_url if config else None) - target_bank_id = bank_id or (config.bank_id if config else None) - target_fact_types = fact_types or (config.fact_types if config else None) - target_budget = budget or (config.recall_budget if config else "mid") - target_max_tokens = max_tokens or (config.max_memory_tokens if config else 4096) + target_bank_id = bank_id or (defaults.bank_id if defaults else None) + target_fact_types = fact_types or (defaults.fact_types if defaults else None) + target_budget = budget or (defaults.budget if defaults else "mid") + target_max_tokens = max_tokens or (defaults.max_memory_tokens if defaults else 4096) if not api_url or not target_bank_id: raise RuntimeError( "Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url." ) + client = None try: - from hindsight_client import Hindsight - - client = Hindsight(base_url=api_url, timeout=30.0) + # Create fresh client for this operation + client = _get_client(api_url) # Call recall API results = client.recall( @@ -178,6 +200,12 @@ def recall( if config and config.verbose: logger.warning(f"Failed to recall memories: {e}") raise + finally: + if client is not None: + try: + client.close() + except Exception: + pass async def arecall( @@ -233,6 +261,7 @@ def reflect( bank_id: Optional[str] = None, budget: Optional[str] = None, context: Optional[str] = None, + response_schema: Optional[dict] = None, hindsight_api_url: Optional[str] = None, ) -> ReflectResult: """Generate a contextual answer based on memories. @@ -246,10 +275,11 @@ def reflect( use different bank_ids per user (e.g., f"user-{user_id}") budget: Budget level for reflection (low, mid, high) context: Additional context to include in the reflection + response_schema: JSON Schema for structured output hindsight_api_url: Override the configured API URL Returns: - ReflectResult with synthesized answer text + ReflectResult with synthesized answer text (or structured_output if schema provided) Raises: RuntimeError: If Hindsight is not configured and no overrides provided @@ -264,28 +294,33 @@ def reflect( Based on our conversations, you're working on a FastAPI project... """ config = get_config() + defaults = get_defaults() api_url = hindsight_api_url or (config.hindsight_api_url if config else None) - target_bank_id = bank_id or (config.bank_id if config else None) - target_budget = budget or (config.recall_budget if config else "mid") + target_bank_id = bank_id or (defaults.bank_id if defaults else None) + target_budget = budget or (defaults.budget if defaults else "mid") if not api_url or not target_bank_id: raise RuntimeError( "Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url." ) + client = None try: - from hindsight_client import Hindsight - - client = Hindsight(base_url=api_url, timeout=30.0) + # Create fresh client for this operation + client = _get_client(api_url) # Call reflect API - result = client.reflect( - bank_id=target_bank_id, - query=query, - budget=target_budget, - context=context, - ) + reflect_kwargs = { + "bank_id": target_bank_id, + "query": query, + "budget": target_budget, + } + if context is not None: + reflect_kwargs["context"] = context + if response_schema is not None: + reflect_kwargs["response_schema"] = response_schema + result = client.reflect(**reflect_kwargs) # Convert to ReflectResult text = result.text if hasattr(result, 'text') else str(result) @@ -310,6 +345,12 @@ def reflect( if config and config.verbose: logger.warning(f"Failed to reflect: {e}") raise + finally: + if client is not None: + try: + client.close() + except Exception: + pass async def areflect( @@ -317,6 +358,7 @@ async def areflect( bank_id: Optional[str] = None, budget: Optional[str] = None, context: Optional[str] = None, + response_schema: Optional[dict] = None, hindsight_api_url: Optional[str] = None, ) -> ReflectResult: """Async version of reflect(). @@ -332,6 +374,7 @@ async def areflect( bank_id=bank_id, budget=budget, context=context, + response_schema=response_schema, hindsight_api_url=hindsight_api_url, ) ) @@ -359,64 +402,20 @@ class RetainResult: return self.success -def retain( +def _retain_sync( content: str, - bank_id: Optional[str] = None, - context: Optional[str] = None, - document_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - hindsight_api_url: Optional[str] = None, + api_url: str, + target_bank_id: str, + context: Optional[str], + target_document_id: Optional[str], + metadata: Optional[Dict[str, str]], + verbose: bool, ) -> RetainResult: - """Store content to Hindsight memory. - - This function allows you to manually store content to memory without - making an LLM call. Useful for storing feedback, user preferences, - or any other information you want the system to remember. - - Args: - content: The text content to store - bank_id: Override the configured bank_id. For multi-user support, - use different bank_ids per user (e.g., f"user-{user_id}") - context: Context description for the memory (e.g., "customer_feedback") - document_id: Optional document ID for grouping related memories - metadata: Optional key-value metadata to attach to the memory - hindsight_api_url: Override the configured API URL - - Returns: - RetainResult indicating success - - Raises: - RuntimeError: If Hindsight is not configured and no overrides provided - - Example: - >>> from hindsight_litellm import configure, retain - >>> configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") - >>> - >>> # Store feedback - >>> retain("User prefers dark mode", context="user_preference") - >>> - >>> # Store with metadata - >>> retain( - ... "Customer reported billing issue resolved", - ... context="support_ticket", - ... metadata={"ticket_id": "12345", "status": "resolved"} - ... ) - """ - config = get_config() - - api_url = hindsight_api_url or (config.hindsight_api_url if config else None) - target_bank_id = bank_id or (config.bank_id if config else None) - target_document_id = document_id or (config.document_id if config else None) - - if not api_url or not target_bank_id: - raise RuntimeError( - "Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url." - ) - + """Internal synchronous retain implementation.""" + client = None try: - from hindsight_client import Hindsight - - client = Hindsight(base_url=api_url, timeout=30.0) + # Create fresh client for this operation + client = _get_client(api_url) # Call retain API result = client.retain( @@ -433,7 +432,7 @@ def retain( # Include debug info if verbose debug_info = None - if config and config.verbose: + if verbose: logger.info(f"Stored content to Hindsight bank: {target_bank_id}") debug_info = RetainDebugInfo( content=content, @@ -449,9 +448,158 @@ def retain( except ImportError as e: raise RuntimeError(f"hindsight-client not installed: {e}") except Exception as e: - if config and config.verbose: + if verbose: logger.warning(f"Failed to retain: {e}") raise + finally: + if client is not None: + try: + client.close() + except Exception: + pass + + +def _retain_background( + content: str, + api_url: str, + target_bank_id: str, + context: Optional[str], + target_document_id: Optional[str], + metadata: Optional[Dict[str, str]], + verbose: bool, +) -> None: + """Background thread worker for async retain.""" + global _retain_errors + try: + _retain_sync( + content=content, + api_url=api_url, + target_bank_id=target_bank_id, + context=context, + target_document_id=target_document_id, + metadata=metadata, + verbose=verbose, + ) + except Exception as e: + with _retain_errors_lock: + _retain_errors.append(e) + logger.warning(f"Background retain failed: {e}") + + +def get_pending_retain_errors() -> List[Exception]: + """Get and clear any pending errors from background retain operations. + + When using async retain (sync=False), errors are collected in the background. + Call this periodically to check for and handle any failures. + + Returns: + List of exceptions from failed background retain operations. + The list is cleared after calling this function. + + Example: + >>> errors = get_pending_retain_errors() + >>> if errors: + ... for e in errors: + ... print(f"Retain failed: {e}") + """ + global _retain_errors + with _retain_errors_lock: + errors = _retain_errors.copy() + _retain_errors.clear() + return errors + + +def retain( + content: str, + bank_id: Optional[str] = None, + context: Optional[str] = None, + document_id: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, + hindsight_api_url: Optional[str] = None, + sync: bool = False, +) -> RetainResult: + """Store content to Hindsight memory. + + This function allows you to manually store content to memory without + making an LLM call. Useful for storing feedback, user preferences, + or any other information you want the system to remember. + + Args: + content: The text content to store + bank_id: Override the configured bank_id. For multi-user support, + use different bank_ids per user (e.g., f"user-{user_id}") + context: Context description for the memory (e.g., "customer_feedback") + document_id: Optional document ID for grouping related memories + metadata: Optional key-value metadata to attach to the memory + hindsight_api_url: Override the configured API URL + sync: If True, block until storage completes. If False (default), + run in background thread for better performance. Use + get_pending_retain_errors() to check for async failures. + + Returns: + RetainResult indicating success. For async mode (sync=False), + always returns success=True immediately; actual errors are + collected via get_pending_retain_errors(). + + Raises: + RuntimeError: If Hindsight is not configured and no overrides provided + Exception: Only raised in sync mode if storage fails + + Example: + >>> from hindsight_litellm import configure, retain + >>> configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888") + >>> + >>> # Async retain (default) - fast, non-blocking + >>> retain("User prefers dark mode", context="user_preference") + >>> + >>> # Sync retain - blocks until complete + >>> retain("Critical data", sync=True) + >>> + >>> # Check for async errors + >>> errors = get_pending_retain_errors() + """ + config = get_config() + defaults = get_defaults() + + api_url = hindsight_api_url or (config.hindsight_api_url if config else None) + target_bank_id = bank_id or (defaults.bank_id if defaults else None) + target_document_id = document_id or (defaults.document_id if defaults else None) + verbose = config.verbose if config else False + + if not api_url or not target_bank_id: + raise RuntimeError( + "Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url." + ) + + if sync: + # Synchronous mode - block and return result + return _retain_sync( + content=content, + api_url=api_url, + target_bank_id=target_bank_id, + context=context, + target_document_id=target_document_id, + metadata=metadata, + verbose=verbose, + ) + else: + # Async mode - run in background thread + thread = threading.Thread( + target=_retain_background, + args=( + content, + api_url, + target_bank_id, + context, + target_document_id, + metadata, + verbose, + ), + daemon=True, + ) + thread.start() + # Return immediate success - actual errors collected via get_pending_retain_errors() + return RetainResult(success=True, items_count=0) async def aretain( @@ -509,7 +657,7 @@ class HindsightOpenAI: store_conversations: bool = True, inject_memories: bool = True, max_memories: Optional[int] = None, - recall_budget: str = "mid", + budget: str = "mid", verbose: bool = False, ): """Initialize the wrapped OpenAI client. @@ -523,7 +671,7 @@ class HindsightOpenAI: store_conversations: Whether to store conversations inject_memories: Whether to inject relevant memories max_memories: Maximum number of memories to inject (None = no limit) - recall_budget: Budget level for memory recall (low, mid, high) + budget: Budget level for memory recall (low, mid, high) verbose: Enable verbose logging """ self._client = client @@ -533,7 +681,7 @@ class HindsightOpenAI: self._store_conversations = store_conversations self._inject_memories = inject_memories self._max_memories = max_memories - self._recall_budget = recall_budget + self._budget = budget self._verbose = verbose self._hindsight_client = None @@ -560,7 +708,7 @@ class HindsightOpenAI: results = client.recall( bank_id=self._bank_id, query=query, - budget=self._recall_budget, + budget=self._budget, max_tokens=self._max_memories * 200 if self._max_memories else 4096, ) @@ -713,7 +861,7 @@ class HindsightAnthropic: store_conversations: bool = True, inject_memories: bool = True, max_memories: Optional[int] = None, - recall_budget: str = "mid", + budget: str = "mid", verbose: bool = False, ): """Initialize the wrapped Anthropic client. @@ -727,7 +875,7 @@ class HindsightAnthropic: store_conversations: Whether to store conversations inject_memories: Whether to inject relevant memories max_memories: Maximum number of memories to inject (None = no limit) - recall_budget: Budget level for memory recall (low, mid, high) + budget: Budget level for memory recall (low, mid, high) verbose: Enable verbose logging """ self._client = client @@ -737,7 +885,7 @@ class HindsightAnthropic: self._store_conversations = store_conversations self._inject_memories = inject_memories self._max_memories = max_memories - self._recall_budget = recall_budget + self._budget = budget self._verbose = verbose self._hindsight_client = None @@ -764,7 +912,7 @@ class HindsightAnthropic: results = client.recall( bank_id=self._bank_id, query=query, - budget=self._recall_budget, + budget=self._budget, max_tokens=self._max_memories * 200 if self._max_memories else 4096, ) @@ -889,7 +1037,7 @@ def wrap_openai( store_conversations: bool = True, inject_memories: bool = True, max_memories: Optional[int] = None, - recall_budget: str = "mid", + budget: str = "mid", verbose: bool = False, ) -> HindsightOpenAI: """Wrap an OpenAI client with Hindsight memory integration. @@ -906,7 +1054,7 @@ def wrap_openai( store_conversations: Whether to store conversations inject_memories: Whether to inject relevant memories max_memories: Maximum number of memories to inject (None = no limit) - recall_budget: Budget level for memory recall (low, mid, high) + budget: Budget level for memory recall (low, mid, high) verbose: Enable verbose logging Returns: @@ -935,7 +1083,7 @@ def wrap_openai( store_conversations=store_conversations, inject_memories=inject_memories, max_memories=max_memories, - recall_budget=recall_budget, + budget=budget, verbose=verbose, ) @@ -948,7 +1096,7 @@ def wrap_anthropic( store_conversations: bool = True, inject_memories: bool = True, max_memories: Optional[int] = None, - recall_budget: str = "mid", + budget: str = "mid", verbose: bool = False, ) -> HindsightAnthropic: """Wrap an Anthropic client with Hindsight memory integration. @@ -965,7 +1113,7 @@ def wrap_anthropic( store_conversations: Whether to store conversations inject_memories: Whether to inject relevant memories max_memories: Maximum number of memories to inject (None = no limit) - recall_budget: Budget level for memory recall (low, mid, high) + budget: Budget level for memory recall (low, mid, high) verbose: Enable verbose logging Returns: @@ -995,6 +1143,6 @@ def wrap_anthropic( store_conversations=store_conversations, inject_memories=inject_memories, max_memories=max_memories, - recall_budget=recall_budget, + budget=budget, verbose=verbose, ) diff --git a/hindsight-integrations/litellm/tests/test_integration.py b/hindsight-integrations/litellm/tests/test_integration.py index 164d4f8a..71a5a181 100644 --- a/hindsight-integrations/litellm/tests/test_integration.py +++ b/hindsight-integrations/litellm/tests/test_integration.py @@ -6,6 +6,8 @@ from typing import List, Dict, Any from hindsight_litellm import ( configure, + set_defaults, + get_defaults, enable, disable, is_enabled, @@ -14,9 +16,10 @@ from hindsight_litellm import ( is_configured, reset_config, HindsightConfig, + HindsightDefaults, MemoryInjectionMode, ) -from hindsight_litellm.callbacks import HindsightCallback, get_callback, cleanup_callback +from hindsight_litellm.callbacks import HindsightCallback class TestConfiguration: @@ -34,47 +37,53 @@ class TestConfiguration: def test_configure_creates_config(self): """Test that configure creates a config object.""" config = configure( - bank_id="test-agent", hindsight_api_url="http://localhost:8888", ) + # Set defaults separately (new API) + defaults = set_defaults(bank_id="test-agent") assert config is not None - assert config.bank_id == "test-agent" assert config.hindsight_api_url == "http://localhost:8888" - assert config.enabled is True + assert defaults.bank_id == "test-agent" def test_configure_with_all_options(self): """Test configure with all options.""" config = configure( hindsight_api_url="http://custom:9999", - bank_id="custom-agent", api_key="secret-key", store_conversations=False, inject_memories=False, injection_mode=MemoryInjectionMode.PREPEND_USER, - max_memories=5, - max_memory_tokens=1000, - recall_budget="high", - fact_types=["world", "opinion"], - document_id="doc-123", - enabled=True, excluded_models=["gpt-3.5*"], verbose=True, + sync_storage=True, + ) + + # Set defaults separately (new API) + defaults = set_defaults( + bank_id="custom-agent", + max_memories=5, + max_memory_tokens=1000, + budget="high", + fact_types=["world", "opinion"], + document_id="doc-123", ) assert config.hindsight_api_url == "http://custom:9999" - assert config.bank_id == "custom-agent" assert config.api_key == "secret-key" assert config.store_conversations is False assert config.inject_memories is False assert config.injection_mode == MemoryInjectionMode.PREPEND_USER - assert config.max_memories == 5 - assert config.max_memory_tokens == 1000 - assert config.recall_budget == "high" - assert config.fact_types == ["world", "opinion"] - assert config.document_id == "doc-123" assert config.excluded_models == ["gpt-3.5*"] assert config.verbose is True + assert config.sync_storage is True + + assert defaults.bank_id == "custom-agent" + assert defaults.max_memories == 5 + assert defaults.max_memory_tokens == 1000 + assert defaults.budget == "high" + assert defaults.fact_types == ["world", "opinion"] + assert defaults.document_id == "doc-123" def test_is_configured_without_bank_id(self): """Test is_configured returns False without bank_id.""" @@ -83,16 +92,19 @@ class TestConfiguration: def test_is_configured_with_bank_id(self): """Test is_configured returns True with bank_id.""" - configure(bank_id="test-agent") + configure(hindsight_api_url="http://localhost:8888") + set_defaults(bank_id="test-agent") assert is_configured() is True def test_reset_config(self): """Test reset_config clears the configuration.""" - configure(bank_id="test-agent") + configure(hindsight_api_url="http://localhost:8888") + set_defaults(bank_id="test-agent") assert is_configured() is True reset_config() assert get_config() is None + assert get_defaults() is None assert is_configured() is False @@ -112,44 +124,42 @@ class TestEnableDisable: with pytest.raises(RuntimeError, match="not configured"): enable() - def test_enable_registers_callback(self): - """Test enable registers callback with LiteLLM.""" - import litellm + def test_enable_without_bank_id_raises(self): + """Test enable raises error without bank_id.""" + configure(hindsight_api_url="http://localhost:8888") + with pytest.raises(RuntimeError, match="bank_id not set"): + enable() - configure(bank_id="test-agent") + def test_enable_sets_enabled_flag(self): + """Test enable sets the enabled flag.""" + configure(hindsight_api_url="http://localhost:8888") + set_defaults(bank_id="test-agent") enable() - callback = get_callback() - assert callback in litellm.callbacks assert is_enabled() is True - def test_disable_removes_callback(self): - """Test disable removes callback from LiteLLM.""" - import litellm - - configure(bank_id="test-agent") + def test_disable_clears_enabled_flag(self): + """Test disable clears the enabled flag.""" + configure(hindsight_api_url="http://localhost:8888") + set_defaults(bank_id="test-agent") enable() assert is_enabled() is True disable() - callback = get_callback() - assert callback not in litellm.callbacks assert is_enabled() is False def test_enable_idempotent(self): """Test enable is idempotent (can be called multiple times).""" - import litellm - - configure(bank_id="test-agent") + configure(hindsight_api_url="http://localhost:8888") + set_defaults(bank_id="test-agent") # Enable multiple times enable() enable() enable() - # Should only have one callback - callback = get_callback() - assert litellm.callbacks.count(callback) == 1 + # Should still be enabled + assert is_enabled() is True class TestCallback: @@ -221,14 +231,21 @@ class TestCallback: def test_format_memories(self): """Test formatting memories into context string.""" callback = HindsightCallback() - config = HindsightConfig(bank_id="test", max_memories=10, verbose=False) + + # Create config and defaults with new API + configure(hindsight_api_url="http://localhost:8888", verbose=False) + set_defaults(bank_id="test", max_memories=10) + + config = get_config() + defaults = get_defaults() memories = [ {"text": "User likes Python", "fact_type": "world", "weight": 0.95}, {"text": "User works at Google", "fact_type": "world", "weight": 0.8}, ] - formatted = callback._format_memories(memories, config) + # Signature is: _format_memories(results, settings, config) + formatted = callback._format_memories(memories, defaults, config) assert "Relevant Memories" in formatted assert "User likes Python" in formatted @@ -238,23 +255,34 @@ class TestCallback: def test_format_memories_with_verbose(self): """Test formatting memories with verbose mode shows weights.""" callback = HindsightCallback() - config = HindsightConfig(bank_id="test", max_memories=10, verbose=True) + + # Create config and defaults with new API + configure(hindsight_api_url="http://localhost:8888", verbose=True) + set_defaults(bank_id="test", max_memories=10) + + config = get_config() + defaults = get_defaults() memories = [ {"text": "User likes Python", "fact_type": "world", "weight": 0.95}, ] - formatted = callback._format_memories(memories, config) + # Signature is: _format_memories(results, settings, config) + formatted = callback._format_memories(memories, defaults, config) assert "relevance: 0.95" in formatted def test_inject_memories_as_system_message(self): """Test injecting memories as system message.""" callback = HindsightCallback() - config = HindsightConfig( - bank_id="test", + + configure( + hindsight_api_url="http://localhost:8888", injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE, ) + set_defaults(bank_id="test") + + config = get_config() messages = [ {"role": "user", "content": "Hello"}, @@ -271,10 +299,14 @@ class TestCallback: def test_inject_memories_prepend_to_existing_system(self): """Test injecting memories appends to existing system message.""" callback = HindsightCallback() - config = HindsightConfig( - bank_id="test", + + configure( + hindsight_api_url="http://localhost:8888", injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE, ) + set_defaults(bank_id="test") + + config = get_config() messages = [ {"role": "system", "content": "You are helpful."}, @@ -292,10 +324,14 @@ class TestCallback: def test_inject_memories_prepend_user_mode(self): """Test injecting memories in prepend_user mode.""" callback = HindsightCallback() - config = HindsightConfig( - bank_id="test", + + configure( + hindsight_api_url="http://localhost:8888", injection_mode=MemoryInjectionMode.PREPEND_USER, ) + set_defaults(bank_id="test") + + config = get_config() messages = [ {"role": "user", "content": "What's my name?"}, @@ -312,10 +348,14 @@ class TestCallback: def test_should_skip_model_exact_match(self): """Test model exclusion with exact match.""" callback = HindsightCallback() - config = HindsightConfig( - bank_id="test", + + configure( + hindsight_api_url="http://localhost:8888", excluded_models=["gpt-3.5-turbo"], ) + set_defaults(bank_id="test") + + config = get_config() assert callback._should_skip_model("gpt-3.5-turbo", config) is True assert callback._should_skip_model("gpt-4", config) is False @@ -323,10 +363,14 @@ class TestCallback: def test_should_skip_model_wildcard(self): """Test model exclusion with wildcard pattern.""" callback = HindsightCallback() - config = HindsightConfig( - bank_id="test", + + configure( + hindsight_api_url="http://localhost:8888", excluded_models=["gpt-3.5*", "claude-instant-*"], ) + set_defaults(bank_id="test") + + config = get_config() assert callback._should_skip_model("gpt-3.5-turbo", config) is True assert callback._should_skip_model("gpt-3.5-turbo-16k", config) is True @@ -414,7 +458,8 @@ class TestContextManager: with hindsight_memory(bank_id="test-agent"): assert is_enabled() is True - assert get_config().bank_id == "test-agent" + defaults = get_defaults() + assert defaults.bank_id == "test-agent" assert is_enabled() is False @@ -423,16 +468,17 @@ class TestContextManager: from hindsight_litellm import hindsight_memory # Set up initial config - configure(bank_id="original-agent") + configure(hindsight_api_url="http://localhost:8888") + set_defaults(bank_id="original-agent") enable() - assert get_config().bank_id == "original-agent" + assert get_defaults().bank_id == "original-agent" # Use context manager with different config with hindsight_memory(bank_id="temporary-agent"): - assert get_config().bank_id == "temporary-agent" + assert get_defaults().bank_id == "temporary-agent" # Should restore original config - assert get_config().bank_id == "original-agent" + assert get_defaults().bank_id == "original-agent" assert is_enabled() is True def test_context_manager_with_fact_types(self): @@ -440,8 +486,8 @@ class TestContextManager: from hindsight_litellm import hindsight_memory with hindsight_memory(bank_id="test-agent", fact_types=["world", "opinion"]): - config = get_config() - assert config.fact_types == ["world", "opinion"] + defaults = get_defaults() + assert defaults.fact_types == ["world", "opinion"] class TestFactTypes: @@ -457,15 +503,75 @@ class TestFactTypes: def test_configure_with_fact_types(self): """Test configuring with fact_types.""" - config = configure( + configure(hindsight_api_url="http://localhost:8888") + defaults = set_defaults( bank_id="test-agent", fact_types=["world", "agent", "opinion"], ) - assert config.fact_types == ["world", "agent", "opinion"] + assert defaults.fact_types == ["world", "agent", "opinion"] def test_configure_without_fact_types(self): """Test configuring without fact_types defaults to None.""" - config = configure(bank_id="test-agent") + configure(hindsight_api_url="http://localhost:8888") + defaults = set_defaults(bank_id="test-agent") - assert config.fact_types is None + assert defaults.fact_types is None + +class TestSetDefaults: + """Tests for set_defaults functionality.""" + + def setup_method(self): + """Reset config before each test.""" + reset_config() + + def teardown_method(self): + """Clean up after each test.""" + cleanup() + + def test_set_defaults_creates_defaults(self): + """Test set_defaults creates a defaults object.""" + defaults = set_defaults(bank_id="test-agent") + + assert defaults is not None + assert defaults.bank_id == "test-agent" + + def test_set_defaults_with_all_options(self): + """Test set_defaults with all options.""" + defaults = set_defaults( + bank_id="test-agent", + document_id="doc-123", + budget="high", + fact_types=["world", "opinion"], + max_memories=10, + max_memory_tokens=2048, + use_reflect=True, + reflect_include_facts=True, + reflect_context="I am a helpful assistant.", + include_entities=False, + trace=True, + ) + + assert defaults.bank_id == "test-agent" + assert defaults.document_id == "doc-123" + assert defaults.budget == "high" + assert defaults.fact_types == ["world", "opinion"] + assert defaults.max_memories == 10 + assert defaults.max_memory_tokens == 2048 + assert defaults.use_reflect is True + assert defaults.reflect_include_facts is True + assert defaults.reflect_context == "I am a helpful assistant." + assert defaults.include_entities is False + assert defaults.trace is True + + def test_set_defaults_updates_existing(self): + """Test set_defaults updates existing defaults.""" + set_defaults(bank_id="first-agent", budget="low") + defaults = set_defaults(budget="high") # Only update budget + + assert defaults.bank_id == "first-agent" # Preserved + assert defaults.budget == "high" # Updated + + def test_get_defaults_returns_none_initially(self): + """Test get_defaults returns None when not set.""" + assert get_defaults() is None