feat(litellm): async retain, reflect support, and API cleanup (#167)

* feat(litellm): async retain with sync option, fix client session cleanup

- Add sync parameter to retain() for blocking vs background operation
- Default to async retain (sync=False) for better performance
- Add get_pending_retain_errors() to check async failures
- Fix "Unclosed client session" warnings by properly closing clients
- Fix "Timeout context manager" asyncio errors by creating fresh clients
- Each API call now creates and closes its own client (aiohttp limitation)
- Add _get_client() and _close_client() helpers for consistent handling
- Update recall(), reflect(), _retain_sync() and _inject_memories()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(litellm): add reflect support and require explicit hindsight_query

- Make hindsight_query required when inject_memories=True to enforce
  intentional memory queries (no automatic last-user-message fallback)
- Add reflect_context parameter for shaping LLM reasoning in reflect
- Add reflect_response_schema for structured JSON output from reflect
- Add _reflect_sync() and _reflect_async() methods in callbacks
- Update wrappers.py to support response_schema in reflect/areflect

This improves the developer experience by making memory injection
explicit and adds full reflect API support through the integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(litellm): rename recall_budget to budget, add per-call reflect context

- Rename `recall_budget` parameter to `budget` for consistency with API
- Add `hindsight_reflect_context` kwarg for per-call reflect context override
- Fix reflect() to not pass None values for optional parameters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs(litellm): update README for new API structure and features

- Document configure() vs set_defaults() separation
- Add hindsight_query requirement when inject_memories=True
- Document async retain (sync=False default) and get_pending_retain_errors()
- Add hindsight_reflect_context per-call override documentation
- Document budget parameter (renamed from recall_budget)
- Add reflect_context and reflect_response_schema options
- Update all code examples to use new API structure
- Add new functions to API Reference table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(litellm): update tests for new configure/set_defaults API

- Update tests to use separate configure() and set_defaults() calls
- Fix test assertions to check config vs defaults appropriately
- Add tests for legacy parameter backwards compatibility
- Add new TestSetDefaults test class
- Fix _format_memories test call signature (settings, config order)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add set_bank_mission(), deprecate set_bank_background()

- Add mission parameter to hindsight_client.create_bank()
- Add set_bank_mission() function to hindsight_litellm
- Deprecate set_bank_background() with DeprecationWarning
- Update _create_or_update_bank() to support mission parameter
- Update README and docstrings to document the new API

The 'background' field has been deprecated in the Hindsight API in favor
of 'mission' which is used for mental model generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove deprecated background parameter and legacy configure() parameters

- Remove set_bank_background() in favor of set_bank_mission()
- Remove background parameter from _create_or_update_bank()
- Remove background parameter from hindsight_client.create_bank()
- Remove legacy parameters from configure() (bank_id, document_id, budget, etc.)
- These have been replaced by the set_defaults() API
- Remove legacy test cases for deprecated parameters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update tests and docs to use mission instead of background

The create_bank() parameter was renamed from background to mission.
Update all tests and doc examples to use the new parameter name.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
This commit is contained in:
DK09876 2026-01-26 02:07:32 -07:00 committed by GitHub
parent 8e39cb7bc8
commit 1d4879a206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1928 additions and 694 deletions

View file

@ -327,7 +327,14 @@ class Hindsight:
background: str | None = None, background: str | None = None,
disposition: dict[str, float] | None = None, disposition: dict[str, float] | None = None,
) -> BankProfileResponse: ) -> 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 from hindsight_client_api.models import create_bank_request, disposition_traits
disposition_obj = None disposition_obj = None
@ -336,7 +343,7 @@ class Hindsight:
request_obj = create_bank_request.CreateBankRequest( request_obj = create_bank_request.CreateBankRequest(
name=name, name=name,
background=background, mission=mission,
disposition=disposition_obj, disposition=disposition_obj,
) )

View file

@ -139,7 +139,7 @@ class TestReflect:
"""Setup: Store some test memories and bank background.""" """Setup: Store some test memories and bank background."""
client.create_bank( client.create_bank(
bank_id=bank_id, 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( client.retain_batch(
@ -261,7 +261,7 @@ class TestEndToEndWorkflow:
# 1. Create bank # 1. Create bank
client.create_bank( client.create_bank(
bank_id=workflow_bank_id, 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 # 2. Store memories
@ -666,7 +666,7 @@ class TestDeleteBank:
# Create bank with some data # Create bank with some data
client.create_bank( client.create_bank(
bank_id=bank_id, bank_id=bank_id,
background="This bank will be deleted", mission="This bank will be deleted",
) )
client.retain( client.retain(
bank_id=bank_id, bank_id=bank_id,

View file

@ -23,7 +23,7 @@ client = Hindsight(base_url=HINDSIGHT_URL)
client.create_bank( client.create_bank(
bank_id="my-bank", bank_id="my-bank",
name="Research Assistant", name="Research Assistant",
background="I am a research assistant specializing in machine learning", mission="I am a research assistant specializing in machine learning",
disposition={ disposition={
"skepticism": 4, "skepticism": 4,
"literalism": 3, "literalism": 3,
@ -33,15 +33,15 @@ client.create_bank(
# [/docs:create-bank] # [/docs:create-bank]
# [docs:bank-background] # [docs:bank-mission]
client.create_bank( client.create_bank(
bank_id="financial-advisor", bank_id="financial-advisor",
name="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 prioritize capital preservation over aggressive growth.
I have seen multiple market crashes and believe in diversification.""" I have seen multiple market crashes and believe in diversification."""
) )
# [/docs:bank-background] # [/docs:bank-mission]
# ============================================================================= # =============================================================================

View file

@ -54,7 +54,7 @@ response = client.reflect(
client.create_bank( client.create_bank(
bank_id="cautious-advisor", bank_id="cautious-advisor",
name="Cautious Advisor", name="Cautious Advisor",
background="I am a risk-aware financial advisor", mission="I am a risk-aware financial advisor",
disposition={ disposition={
"skepticism": 5, # Very skeptical of claims "skepticism": 5, # Very skeptical of claims
"literalism": 4, # Focuses on exact requirements "literalism": 4, # Focuses on exact requirements

View file

@ -5,13 +5,14 @@ Universal LLM memory integration via LiteLLM. Add persistent memory to any LLM a
## Features ## Features
- **Universal LLM Support** - Works with 100+ LLM providers via LiteLLM (OpenAI, Anthropic, Groq, Azure, AWS Bedrock, Google Vertex AI, and more) - **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 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) - **Two Memory Modes** - Choose between `reflect` (synthesized context) or `recall` (raw memory retrieval)
- **Direct Memory APIs** - Query, synthesize, and store memories manually - **Direct Memory APIs** - Query, synthesize, and store memories manually
- **Native Client Wrappers** - Alternative wrappers for OpenAI and Anthropic SDKs - **Native Client Wrappers** - Alternative wrappers for OpenAI and Anthropic SDKs
- **Debug Mode** - Inspect exactly what memories are being injected - **Debug Mode** - Inspect exactly what memories are being injected
- **Async Error Tracking** - Check for background operation failures with `get_pending_retain_errors()`
## Installation ## Installation
@ -24,20 +25,31 @@ pip install hindsight-litellm
```python ```python
import hindsight_litellm import hindsight_litellm
# Configure and enable memory integration # Step 1: Configure static settings
hindsight_litellm.configure( hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888", 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() 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( response = hindsight_litellm.completion(
model="gpt-4o-mini", 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 ## How It Works
Here's what happens under the hood when you call `completion()`: 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 ## 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 ```python
hindsight_litellm.configure( hindsight_litellm.configure(
# Required # Required
hindsight_api_url="http://localhost:8888", # Hindsight API server URL 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 # Optional - Memory behavior
store_conversations=True, # Store conversations after LLM calls store_conversations=True, # Store conversations after LLM calls
inject_memories=True, # Inject relevant memories into prompts inject_memories=True, # Inject relevant memories into prompts
use_reflect=False, # Use reflect API (synthesized) vs recall (raw memories) sync_storage=False, # False = async storage (default, better performance)
reflect_include_facts=False, # Include source facts with reflect responses # True = sync storage (blocks, raises errors immediately)
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)
# Optional - Advanced # Optional - Advanced
injection_mode="system_message", # or "prepend_user" injection_mode="system_message", # How to inject: "system_message" or "prepend_user"
excluded_models=["gpt-3.5*"], # Exclude certain models excluded_models=["gpt-3.5*"], # Exclude certain models from interception
verbose=True, # Enable verbose logging and debug info 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. Default values for per-call settings. These can be overridden on individual calls using `hindsight_*` kwargs:
- **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.
```python ```python
# Example: Customer support routing agent hindsight_litellm.set_defaults(
hindsight_litellm.configure( # Required
hindsight_api_url="http://localhost:8888", bank_id="my-agent", # Memory bank ID
bank_id="support-router",
bank_name="Customer Support Router", # Optional - Memory retrieval
background="""This agent routes customer support requests to the appropriate team. budget="mid", # Budget level: "low", "mid", "high"
Remember which types of issues should go to which teams (billing, technical, sales). fact_types=["world", "opinion"], # Filter fact types to retrieve
Track customer preferences for communication channels and past issue resolutions. max_memories=10, # Maximum memories to inject (None = unlimited)
Note any escalation patterns or VIP customers who need special handling.""", 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 ### 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. - **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 ```python
# Recall mode - raw memories # Recall mode - raw memories
hindsight_litellm.configure( hindsight_litellm.set_defaults(bank_id="my-agent", use_reflect=False)
bank_id="my-agent",
use_reflect=False, # Default
)
# Injects: "1. [WORLD] User prefers Python\n2. [OPINION] User dislikes Java..." # Injects: "1. [WORLD] User prefers Python\n2. [OPINION] User dislikes Java..."
# Reflect mode - synthesized context # 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", bank_id="my-agent",
use_reflect=True, 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 ## Multi-Provider Support
@ -199,29 +245,29 @@ Works with any LiteLLM-supported provider:
```python ```python
import hindsight_litellm import hindsight_litellm
hindsight_litellm.configure( hindsight_litellm.configure(hindsight_api_url="http://localhost:8888")
hindsight_api_url="http://localhost:8888", hindsight_litellm.set_defaults(bank_id="my-agent")
bank_id="my-agent",
)
hindsight_litellm.enable() hindsight_litellm.enable()
messages = [{"role": "user", "content": "Hello!"}]
# OpenAI # OpenAI
hindsight_litellm.completion(model="gpt-4o", messages=[...]) hindsight_litellm.completion(model="gpt-4o", messages=messages, hindsight_query="greeting")
# Anthropic # 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 # 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 # Azure OpenAI
hindsight_litellm.completion(model="azure/gpt-4", messages=[...]) hindsight_litellm.completion(model="azure/gpt-4", messages=messages, hindsight_query="greeting")
# AWS Bedrock # 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 # 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 ## Direct Memory APIs
@ -229,9 +275,10 @@ hindsight_litellm.completion(model="vertex_ai/gemini-pro", messages=[...])
### Recall - Query raw memories ### Recall - Query raw memories
```python ```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 # Query memories
memories = recall("what projects am I working on?", budget="mid") memories = recall("what projects am I working on?", budget="mid")
@ -246,9 +293,10 @@ for m in memories:
### Reflect - Get synthesized context ### Reflect - Get synthesized context
```python ```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 # Get synthesized memory context
result = reflect("what do you know about the user's preferences?") result = reflect("what do you know about the user's preferences?")
@ -256,21 +304,42 @@ print(result.text)
# Output: # Output:
# "Based on our conversations, the user prefers Python for backend development..." # "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 ### Retain - Store memories
```python ```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( result = retain(
content="User mentioned they're working on a machine learning project", content="User mentioned they're working on a machine learning project",
context="Discussion about current projects", 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 ### Async APIs
@ -332,19 +401,16 @@ response = wrapped.messages.create(
When `verbose=True`, you can inspect exactly what memories are being injected: When `verbose=True`, you can inspect exactly what memories are being injected:
```python ```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( configure(hindsight_api_url="http://localhost:8888", verbose=True)
bank_id="my-agent", set_defaults(bank_id="my-agent", use_reflect=True)
hindsight_api_url="http://localhost:8888",
verbose=True,
use_reflect=True,
)
enable() enable()
response = completion( response = completion(
model="gpt-4o-mini", 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 # Inspect what was injected
@ -365,7 +431,11 @@ from hindsight_litellm import hindsight_memory
import litellm import litellm
with hindsight_memory(bank_id="user-123"): 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 # Memory integration automatically disabled after context
``` ```
@ -387,7 +457,8 @@ cleanup()
| Function | Description | | 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 | | `enable()` | Enable memory integration with LiteLLM |
| `disable()` | Disable memory integration | | `disable()` | Disable memory integration |
| `is_enabled()` | Check if memory integration is enabled | | `is_enabled()` | Check if memory integration is enabled |
@ -397,20 +468,30 @@ cleanup()
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
| `get_config()` | Get current configuration | | `get_config()` | Get current static configuration |
| `is_configured()` | Check if Hindsight is configured | | `get_defaults()` | Get current per-call defaults |
| `reset_config()` | Reset configuration to 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 ### Memory Functions
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
| `recall(query, ...)` | Synchronously query raw memories | | `recall(query, ...)` | Query raw memories (sync) |
| `arecall(query, ...)` | Asynchronously query raw memories | | `arecall(query, ...)` | Query raw memories (async) |
| `reflect(query, ...)` | Synchronously get synthesized memory context | | `reflect(query, ...)` | Get synthesized memory context (sync) |
| `areflect(query, ...)` | Asynchronously get synthesized memory context | | `areflect(query, ...)` | Get synthesized memory context (async) |
| `retain(content, ...)` | Synchronously store a memory | | `retain(content, sync=False, ...)` | Store a memory (async by default, use `sync=True` to block) |
| `aretain(content, ...)` | Asynchronously store a memory | | `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 ### Debug Functions

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,14 @@ import concurrent.futures
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import ModelResponse 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 # Use requests for sync HTTP calls to avoid async event loop issues
try: try:
@ -37,6 +44,16 @@ except ImportError:
logger = logging.getLogger(__name__) 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 # Thread pool for running async operations in background
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="hindsight-") _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._recent_hashes: Set[str] = set()
self._max_hash_cache = 1000 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): def _get_http_session(self):
"""Get or create a requests Session (thread-safe).""" """Get or create a requests Session (thread-safe)."""
if self._http_session is None: if self._http_session is None:
@ -92,14 +146,18 @@ class HindsightCallback(CustomLogger):
) )
return self._http_session return self._http_session
def _http_post(self, url: str, json_data: dict, config: HindsightConfig) -> Optional[dict]: def _http_post(self, url: str, json_data: dict, config: HindsightConfig) -> dict:
"""Make a synchronous HTTP POST request.""" """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}"
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: if HAS_REQUESTS:
response = session.post(url, json=json_data, headers=headers, timeout=30) response = session.post(url, json=json_data, headers=headers, timeout=30)
response.raise_for_status() response.raise_for_status()
@ -108,10 +166,14 @@ class HindsightCallback(CustomLogger):
response = session.post(url, json=json_data, headers=headers) response = session.post(url, json=json_data, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
else:
raise HindsightError("No HTTP client available (install requests or httpx)")
except HindsightError:
raise
except Exception as e: except Exception as e:
if config.verbose: if config.verbose:
logger.warning(f"HTTP POST failed: {e}") logger.error(f"HTTP POST failed: {e}")
return None raise HindsightError(f"Hindsight API request failed: {e}") from e
def _should_skip_model(self, model: str, config: HindsightConfig) -> bool: def _should_skip_model(self, model: str, config: HindsightConfig) -> bool:
"""Check if this model should be excluded from interception.""" """Check if this model should be excluded from interception."""
@ -138,6 +200,20 @@ class HindsightCallback(CustomLogger):
return " ".join(text_parts) return " ".join(text_parts)
return None 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( def _compute_conversation_hash(
self, self,
user_input: str, user_input: str,
@ -163,6 +239,7 @@ class HindsightCallback(CustomLogger):
def _format_memories( def _format_memories(
self, self,
results: List[Any], results: List[Any],
settings: HindsightDefaults,
config: HindsightConfig config: HindsightConfig
) -> str: ) -> str:
"""Format memory recall results into a context string. """Format memory recall results into a context string.
@ -174,7 +251,7 @@ class HindsightCallback(CustomLogger):
return "" return ""
# Apply limit if set, otherwise use all results # 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 = [] memory_lines = []
for i, result in enumerate(results_to_use, 1): for i, result in enumerate(results_to_use, 1):
# Handle both RecallResult objects and dicts # Handle both RecallResult objects and dicts
@ -248,194 +325,343 @@ class HindsightCallback(CustomLogger):
return updated_messages 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( def _recall_memories_sync(
self, self,
query: str, query: str,
settings: HindsightDefaults,
config: HindsightConfig config: HindsightConfig
) -> List[Dict[str, Any]]: ) -> 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: 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) response = self._http_post(url, request_data, config)
if response and "results" in response: if response and "results" in response:
return response["results"] return response["results"]
return [] return []
except HindsightError as e:
except Exception as e:
if config.verbose: if config.verbose:
logger.warning(f"Failed to recall memories: {e}") logger.error(f"Failed to recall memories: {e}")
return [] raise HindsightError(f"Memory recall failed: {e}") from e
async def _recall_memories_async( async def _recall_memories_async(
self, self,
query: str, query: str,
settings: HindsightDefaults,
config: HindsightConfig config: HindsightConfig
) -> List[Any]: ) -> List[Any]:
"""Recall relevant memories from Hindsight (async). """Recall relevant memories from Hindsight (async).
Uses thread pool executor with sync HTTP to avoid event loop conflicts. 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()
loop = asyncio.get_running_loop() results = await loop.run_in_executor(
results = await loop.run_in_executor( _executor,
_executor, lambda: self._recall_memories_sync(query, settings, config)
self._recall_memories_sync, )
query,
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: if config.verbose:
logger.warning(f"Failed to recall memories: {e}") logger.error(f"Failed to reflect: {e}")
return [] 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( def _store_conversation_sync(
self, self,
messages: List[Dict[str, Any]], messages: List[Dict[str, Any]],
response: ModelResponse, response: ModelResponse,
model: str, model: str,
settings: HindsightDefaults,
config: HindsightConfig, config: HindsightConfig,
) -> None: ) -> None:
"""Store the conversation to Hindsight (sync) using direct HTTP. """Store the conversation to Hindsight (sync) using direct HTTP.
By default, stores the full conversation history passed to the LLM. IMPORTANT: This intentionally sends the FULL conversation history each call,
Each message is stored as a separate item, all linked by document_id. 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: bank_id = settings.bank_id
# Extract assistant response from the LLM response if not bank_id:
assistant_output = "" raise HindsightError(
if response.choices and len(response.choices) > 0: "No bank_id configured. Call set_defaults(bank_id=...) "
choice = response.choices[0] "or pass hindsight_bank_id=... to the completion call."
if hasattr(choice, "message") and choice.message: )
assistant_output = choice.message.content or ""
if not assistant_output: # Extract assistant response from the LLM response
return 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 # Skip if no content AND no tool calls - nothing to store
# All linked by document_id for Hindsight to process together if not assistant_output and not assistant_tool_calls:
items = [] return
for msg in messages:
role = msg.get("role", "").upper()
content = msg.get("content", "")
# Skip system messages - they're instructions, not conversation # Build conversation items - each message becomes a separate item
if role == "SYSTEM": # All linked by document_id for Hindsight to process together
continue items = []
for msg in messages:
role = msg.get("role", "").upper()
content = msg.get("content", "")
# Skip if this looks like our injected memory context # Skip system messages - they're instructions, not conversation
if isinstance(content, str) and content.startswith("# Relevant Memories"): if role == "SYSTEM":
continue continue
# Handle structured content (e.g., vision messages) # Skip if this looks like our injected memory context
if isinstance(content, list): if isinstance(content, str) and content.startswith("# Relevant Memories"):
text_parts = [] continue
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
content = " ".join(text_parts)
# 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: if content:
# Map roles to clearer labels items.append(f"ASSISTANT: {content}")
label = "USER" if role == "USER" else "ASSISTANT" continue
items.append(f"{label}: {content}")
# 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}") items.append(f"ASSISTANT: {assistant_output}")
if assistant_tool_calls:
items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(assistant_tool_calls)}")
if not items: if not items:
return return
# Use last user message for deduplication hash # Use last user message for deduplication hash
user_input = self._extract_user_query(messages) or "" user_input = self._extract_user_query(messages) or ""
# Deduplication check # Deduplication check - include tool calls if no text content
conv_hash = self._compute_conversation_hash(user_input, assistant_output) dedup_output = assistant_output or ";".join(assistant_tool_calls)
if self._is_duplicate(conv_hash): conv_hash = self._compute_conversation_hash(user_input, dedup_output)
if config.verbose: if self._is_duplicate(conv_hash):
logger.debug(f"Skipping duplicate conversation: {conv_hash}") if config.verbose:
return logger.debug(f"Skipping duplicate conversation: {conv_hash}")
return
# Build the full conversation as a single item for now # Build the full conversation as a single item for now
# (Future: could store each message as separate item in same document) # (Future: could store each message as separate item in same document)
conversation_text = "\n\n".join(items) conversation_text = "\n\n".join(items)
# Build metadata # Build metadata
metadata = { metadata = {
"source": "litellm", "source": "litellm",
"model": model, "model": model,
} }
# Add token usage if available # Add token usage if available
if hasattr(response, "usage") and response.usage: if hasattr(response, "usage") and response.usage:
if hasattr(response.usage, "total_tokens"): if hasattr(response.usage, "total_tokens"):
metadata["tokens"] = str(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 = { request_data = {
"items": [ "items": [
{ {
"content": conversation_text, "content": conversation_text,
"context": f"conversation:litellm:{model}", "context": f"conversation:litellm:{model}",
"metadata": metadata, "metadata": metadata,
"document_id": config.document_id, # Group by document "document_id": settings.document_id, # Group by document
} }
], ],
} }
try:
self._http_post(url, request_data, config) self._http_post(url, request_data, config)
if config.verbose: if config.verbose:
logger.info(f"Stored conversation to Hindsight bank: {config.bank_id}") logger.info(f"Stored conversation to Hindsight bank: {bank_id}")
except HindsightError as e:
except Exception as e:
if config.verbose: 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( async def _store_conversation_async(
self, self,
messages: List[Dict[str, Any]], messages: List[Dict[str, Any]],
response: ModelResponse, response: ModelResponse,
model: str, model: str,
settings: HindsightDefaults,
config: HindsightConfig, config: HindsightConfig,
) -> None: ) -> None:
"""Store the conversation to Hindsight (async). """Store the conversation to Hindsight (async).
Uses thread pool executor with sync HTTP to avoid event loop conflicts. 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()
loop = asyncio.get_running_loop() await loop.run_in_executor(
await loop.run_in_executor( _executor,
_executor, lambda: self._store_conversation_sync(
self._store_conversation_sync, messages, response, model, settings, config
messages,
response,
model,
config
) )
except Exception as e: )
if config.verbose:
logger.warning(f"Failed to store conversation: {e}")
# ========== LiteLLM CustomLogger Interface ========== # ========== LiteLLM CustomLogger Interface ==========
@ -449,28 +675,53 @@ class HindsightCallback(CustomLogger):
This is where we inject memories into the messages. 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 return
config = get_config() # Get effective settings (kwargs override defaults)
if not config or not config.enabled or not config.inject_memories: settings = self._get_effective_settings(kwargs)
return 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): if self._should_skip_model(model, config):
return return
# Extract user query # hindsight_query is required when inject_memories=True
user_query = self._extract_user_query(messages) custom_query = kwargs.get("hindsight_query")
if not user_query: if not custom_query:
return 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 user_query = custom_query
memories = self._recall_memories_sync(user_query, config)
if not memories: # Use reflect or recall based on settings
return 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( updated_messages = self._inject_memories_into_messages(
messages, memory_context, config messages, memory_context, config
) )
@ -480,7 +731,8 @@ class HindsightCallback(CustomLogger):
messages.extend(updated_messages) messages.extend(updated_messages)
if config.verbose: 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( async def async_log_pre_api_call(
self, self,
@ -492,28 +744,53 @@ class HindsightCallback(CustomLogger):
This is where we inject memories into the messages. 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 return
config = get_config() # Get effective settings (kwargs override defaults)
if not config or not config.enabled or not config.inject_memories: settings = self._get_effective_settings(kwargs)
return 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): if self._should_skip_model(model, config):
return return
# Extract user query # hindsight_query is required when inject_memories=True
user_query = self._extract_user_query(messages) custom_query = kwargs.get("hindsight_query")
if not user_query: if not custom_query:
return 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 user_query = custom_query
memories = await self._recall_memories_async(user_query, config)
if not memories: # Use reflect or recall based on settings
return 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( updated_messages = self._inject_memories_into_messages(
messages, memory_context, config messages, memory_context, config
) )
@ -523,7 +800,8 @@ class HindsightCallback(CustomLogger):
messages.extend(updated_messages) messages.extend(updated_messages)
if config.verbose: 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( def log_success_event(
self, self,
@ -536,11 +814,14 @@ class HindsightCallback(CustomLogger):
This is where we store the conversation. This is where we store the conversation.
""" """
if not is_configured(): config = get_config()
if not config or not config.store_conversations:
return return
config = get_config() # Get effective settings (kwargs override defaults)
if not config or not config.enabled or not config.store_conversations: settings = self._get_effective_settings(kwargs)
if not settings.bank_id:
# bank_id validation already done in log_pre_api_call
return return
model = kwargs.get("model", "unknown") model = kwargs.get("model", "unknown")
@ -552,7 +833,7 @@ class HindsightCallback(CustomLogger):
return return
# Store the conversation # 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( async def async_log_success_event(
self, self,
@ -565,11 +846,14 @@ class HindsightCallback(CustomLogger):
This is where we store the conversation. This is where we store the conversation.
""" """
if not is_configured(): config = get_config()
if not config or not config.store_conversations:
return return
config = get_config() # Get effective settings (kwargs override defaults)
if not config or not config.enabled or not config.store_conversations: settings = self._get_effective_settings(kwargs)
if not settings.bank_id:
# bank_id validation already done in async_log_pre_api_call
return return
model = kwargs.get("model", "unknown") model = kwargs.get("model", "unknown")
@ -581,7 +865,7 @@ class HindsightCallback(CustomLogger):
return return
# Store the conversation # 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( def log_failure_event(
self, self,

View file

@ -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 dataclasses import dataclass, field
from enum import Enum from enum import Enum
class MemoryInjectionMode(str, Enum): class MemoryInjectionMode(str, Enum):
"""How memories should be injected into the prompt.""" """How memories should be injected into the prompt.
SYSTEM_MESSAGE = "system_message" # Add as system message
PREPEND_USER = "prepend_user" # Prepend to user message Use inject_memories=False if you don't want memory injection.
DISABLED = "disabled" # Don't inject memories """
SYSTEM_MESSAGE = "system_message" # Add to/create system message
PREPEND_USER = "prepend_user" # Prepend to last user message
@dataclass @dataclass
class HindsightConfig: 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: Attributes:
hindsight_api_url: URL of the Hindsight API server 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 api_key: Optional API key for Hindsight authentication
store_conversations: Whether to store conversations to Hindsight store_conversations: Whether to store conversations to Hindsight
inject_memories: Whether to inject relevant memories into prompts inject_memories: Whether to inject relevant memories into prompts
injection_mode: How to inject memories (system_message or prepend_user) 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 excluded_models: List of model patterns to exclude from interception
verbose: Enable verbose logging verbose: Enable verbose logging
bank_name: Optional display name for the memory bank sync_storage: If True, storage runs synchronously and raises errors immediately.
background: Optional background/instructions for memory extraction If False (default), storage runs in background thread for better performance.
use_reflect: Use reflect API instead of recall for memory injection (synthesizes answer)
""" """
hindsight_api_url: str = "http://localhost:8888" hindsight_api_url: str = "http://localhost:8888"
bank_id: Optional[str] = None
api_key: Optional[str] = None api_key: Optional[str] = None
store_conversations: bool = True store_conversations: bool = True
inject_memories: bool = True inject_memories: bool = True
injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE 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) excluded_models: List[str] = field(default_factory=list)
verbose: bool = False verbose: bool = False
bank_name: Optional[str] = None # Display name for the memory bank sync_storage: bool = False
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
# 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_config: Optional[HindsightConfig] = None
_global_defaults: Optional[HindsightDefaults] = None
def configure( def configure(
hindsight_api_url: str = "http://localhost:8888", hindsight_api_url: str = "http://localhost:8888",
bank_id: Optional[str] = None,
api_key: Optional[str] = None, api_key: Optional[str] = None,
store_conversations: bool = True, store_conversations: bool = True,
inject_memories: bool = True, inject_memories: bool = True,
injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE, 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, excluded_models: Optional[List[str]] = None,
verbose: bool = False, verbose: bool = False,
bank_name: Optional[str] = None, sync_storage: bool = False,
background: Optional[str] = None,
use_reflect: bool = False,
reflect_include_facts: bool = False,
) -> HindsightConfig: ) -> 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 This sets up settings that typically don't change during a session.
LiteLLM callbacks to inject memories and store conversations. For per-call settings like bank_id, use set_defaults() or per-call kwargs.
Args: Args:
hindsight_api_url: URL of the Hindsight API server 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 api_key: Optional API key for Hindsight authentication
store_conversations: Whether to store conversations to Hindsight store_conversations: Whether to store conversations to Hindsight
inject_memories: Whether to inject relevant memories into prompts inject_memories: Whether to inject relevant memories into prompts
injection_mode: How to inject memories into the prompt 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 excluded_models: List of model patterns to exclude from interception
verbose: Enable verbose logging verbose: Enable verbose logging
bank_name: Optional display name for the memory bank sync_storage: If True, storage runs synchronously and raises errors immediately.
background: Optional background/instructions that help Hindsight understand If False (default), storage runs in background for better performance.
what information is important to extract and remember from conversations. Use get_pending_storage_errors() to check for async storage failures.
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.
Returns: Returns:
The configured HindsightConfig instance The configured HindsightConfig instance
Example: Example:
>>> from hindsight_litellm import configure, enable >>> from hindsight_litellm import configure, set_defaults, enable
>>> configure( >>> configure(
... hindsight_api_url="http://localhost:8888", ... hindsight_api_url="http://localhost:8888",
... bank_id="user-123", # Per-user bank for multi-user support ... api_key="your-api-key",
... store_conversations=True, ... verbose=True,
... inject_memories=True,
... background="This agent routes customer requests to support channels. "
... "Remember which types of issues should go to which channels.",
... ) ... )
>>> enable() # Register callbacks with LiteLLM >>> set_defaults(bank_id="user-123")
>>> enable() # Start memory integration
""" """
global _global_config global _global_config
_global_config = HindsightConfig( _global_config = HindsightConfig(
hindsight_api_url=hindsight_api_url, hindsight_api_url=hindsight_api_url,
bank_id=bank_id,
api_key=api_key, api_key=api_key,
store_conversations=store_conversations, store_conversations=store_conversations,
inject_memories=inject_memories, inject_memories=inject_memories,
injection_mode=injection_mode, 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 [], excluded_models=excluded_models or [],
verbose=verbose, verbose=verbose,
bank_name=bank_name, sync_storage=sync_storage,
background=background,
use_reflect=use_reflect,
reflect_include_facts=reflect_include_facts,
) )
# 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 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( def _create_or_update_bank(
hindsight_api_url: str, hindsight_api_url: str,
bank_id: str, bank_id: str,
name: Optional[str] = None, name: Optional[str] = None,
background: Optional[str] = None, mission: Optional[str] = None,
verbose: bool = False, verbose: bool = False,
) -> None: ) -> None:
"""Create or update a memory bank with the given configuration. """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: try:
from hindsight_client import Hindsight from hindsight_client import Hindsight
@ -182,18 +261,18 @@ def _create_or_update_bank(
client.create_bank( client.create_bank(
bank_id=bank_id, bank_id=bank_id,
name=name, name=name,
background=background, mission=mission,
) )
if verbose: if verbose:
import logging import logging
logging.getLogger("hindsight_litellm").info( logging.getLogger("hindsight_litellm").info(
f"Created/updated bank '{bank_id}' with background" f"Created/updated bank '{bank_id}' with mission"
) )
except ImportError: except ImportError:
if verbose: if verbose:
import logging import logging
logging.getLogger("hindsight_litellm").warning( 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" "Install with: pip install hindsight-client"
) )
except Exception as e: except Exception as e:
@ -205,7 +284,7 @@ def _create_or_update_bank(
def get_config() -> Optional[HindsightConfig]: def get_config() -> Optional[HindsightConfig]:
"""Get the current global configuration. """Get the current global static configuration.
Returns: Returns:
The current HindsightConfig instance, or None if not configured The current HindsightConfig instance, or None if not configured
@ -213,20 +292,136 @@ def get_config() -> Optional[HindsightConfig]:
return _global_config return _global_config
def is_configured() -> bool: def get_defaults() -> Optional[HindsightDefaults]:
"""Check if Hindsight has been configured. """Get the current global defaults for per-call settings.
Returns: 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 ( return (
_global_config is not None _global_config is not None
and _global_config.enabled and _global_defaults is not None
and _global_config.bank_id is not None and _global_defaults.bank_id is not None
) )
def reset_config() -> None: def reset_config() -> None:
"""Reset the global configuration to None.""" """Reset all global configuration to None."""
global _global_config global _global_config, _global_defaults
_global_config = None _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,
)

View file

@ -8,15 +8,36 @@ integration with native client libraries.
""" """
import logging import logging
import threading
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from dataclasses import dataclass 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__) 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 @dataclass
class RecallResult: class RecallResult:
"""A single memory recall result.""" """A single memory recall result."""
@ -106,24 +127,25 @@ def recall(
>>> if memories.debug: >>> if memories.debug:
... print(f"Queried bank: {memories.debug.bank_id}") ... print(f"Queried bank: {memories.debug.bank_id}")
""" """
# Get config or use overrides # Get config and defaults, or use overrides
config = get_config() config = get_config()
defaults = get_defaults()
api_url = hindsight_api_url or (config.hindsight_api_url if config else None) 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_bank_id = bank_id or (defaults.bank_id if defaults else None)
target_fact_types = fact_types or (config.fact_types if config else None) target_fact_types = fact_types or (defaults.fact_types if defaults else None)
target_budget = budget or (config.recall_budget if config else "mid") target_budget = budget or (defaults.budget if defaults else "mid")
target_max_tokens = max_tokens or (config.max_memory_tokens if config else 4096) target_max_tokens = max_tokens or (defaults.max_memory_tokens if defaults else 4096)
if not api_url or not target_bank_id: if not api_url or not target_bank_id:
raise RuntimeError( raise RuntimeError(
"Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url." "Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url."
) )
client = None
try: try:
from hindsight_client import Hindsight # Create fresh client for this operation
client = _get_client(api_url)
client = Hindsight(base_url=api_url, timeout=30.0)
# Call recall API # Call recall API
results = client.recall( results = client.recall(
@ -178,6 +200,12 @@ def recall(
if config and config.verbose: if config and config.verbose:
logger.warning(f"Failed to recall memories: {e}") logger.warning(f"Failed to recall memories: {e}")
raise raise
finally:
if client is not None:
try:
client.close()
except Exception:
pass
async def arecall( async def arecall(
@ -233,6 +261,7 @@ def reflect(
bank_id: Optional[str] = None, bank_id: Optional[str] = None,
budget: Optional[str] = None, budget: Optional[str] = None,
context: Optional[str] = None, context: Optional[str] = None,
response_schema: Optional[dict] = None,
hindsight_api_url: Optional[str] = None, hindsight_api_url: Optional[str] = None,
) -> ReflectResult: ) -> ReflectResult:
"""Generate a contextual answer based on memories. """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}") use different bank_ids per user (e.g., f"user-{user_id}")
budget: Budget level for reflection (low, mid, high) budget: Budget level for reflection (low, mid, high)
context: Additional context to include in the reflection context: Additional context to include in the reflection
response_schema: JSON Schema for structured output
hindsight_api_url: Override the configured API URL hindsight_api_url: Override the configured API URL
Returns: Returns:
ReflectResult with synthesized answer text ReflectResult with synthesized answer text (or structured_output if schema provided)
Raises: Raises:
RuntimeError: If Hindsight is not configured and no overrides provided 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... Based on our conversations, you're working on a FastAPI project...
""" """
config = get_config() config = get_config()
defaults = get_defaults()
api_url = hindsight_api_url or (config.hindsight_api_url if config else None) 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_bank_id = bank_id or (defaults.bank_id if defaults else None)
target_budget = budget or (config.recall_budget if config else "mid") target_budget = budget or (defaults.budget if defaults else "mid")
if not api_url or not target_bank_id: if not api_url or not target_bank_id:
raise RuntimeError( raise RuntimeError(
"Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url." "Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url."
) )
client = None
try: try:
from hindsight_client import Hindsight # Create fresh client for this operation
client = _get_client(api_url)
client = Hindsight(base_url=api_url, timeout=30.0)
# Call reflect API # Call reflect API
result = client.reflect( reflect_kwargs = {
bank_id=target_bank_id, "bank_id": target_bank_id,
query=query, "query": query,
budget=target_budget, "budget": target_budget,
context=context, }
) 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 # Convert to ReflectResult
text = result.text if hasattr(result, 'text') else str(result) text = result.text if hasattr(result, 'text') else str(result)
@ -310,6 +345,12 @@ def reflect(
if config and config.verbose: if config and config.verbose:
logger.warning(f"Failed to reflect: {e}") logger.warning(f"Failed to reflect: {e}")
raise raise
finally:
if client is not None:
try:
client.close()
except Exception:
pass
async def areflect( async def areflect(
@ -317,6 +358,7 @@ async def areflect(
bank_id: Optional[str] = None, bank_id: Optional[str] = None,
budget: Optional[str] = None, budget: Optional[str] = None,
context: Optional[str] = None, context: Optional[str] = None,
response_schema: Optional[dict] = None,
hindsight_api_url: Optional[str] = None, hindsight_api_url: Optional[str] = None,
) -> ReflectResult: ) -> ReflectResult:
"""Async version of reflect(). """Async version of reflect().
@ -332,6 +374,7 @@ async def areflect(
bank_id=bank_id, bank_id=bank_id,
budget=budget, budget=budget,
context=context, context=context,
response_schema=response_schema,
hindsight_api_url=hindsight_api_url, hindsight_api_url=hindsight_api_url,
) )
) )
@ -359,64 +402,20 @@ class RetainResult:
return self.success return self.success
def retain( def _retain_sync(
content: str, content: str,
bank_id: Optional[str] = None, api_url: str,
context: Optional[str] = None, target_bank_id: str,
document_id: Optional[str] = None, context: Optional[str],
metadata: Optional[Dict[str, str]] = None, target_document_id: Optional[str],
hindsight_api_url: Optional[str] = None, metadata: Optional[Dict[str, str]],
verbose: bool,
) -> RetainResult: ) -> RetainResult:
"""Store content to Hindsight memory. """Internal synchronous retain implementation."""
client = None
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."
)
try: try:
from hindsight_client import Hindsight # Create fresh client for this operation
client = _get_client(api_url)
client = Hindsight(base_url=api_url, timeout=30.0)
# Call retain API # Call retain API
result = client.retain( result = client.retain(
@ -433,7 +432,7 @@ def retain(
# Include debug info if verbose # Include debug info if verbose
debug_info = None debug_info = None
if config and config.verbose: if verbose:
logger.info(f"Stored content to Hindsight bank: {target_bank_id}") logger.info(f"Stored content to Hindsight bank: {target_bank_id}")
debug_info = RetainDebugInfo( debug_info = RetainDebugInfo(
content=content, content=content,
@ -449,9 +448,158 @@ def retain(
except ImportError as e: except ImportError as e:
raise RuntimeError(f"hindsight-client not installed: {e}") raise RuntimeError(f"hindsight-client not installed: {e}")
except Exception as e: except Exception as e:
if config and config.verbose: if verbose:
logger.warning(f"Failed to retain: {e}") logger.warning(f"Failed to retain: {e}")
raise 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( async def aretain(
@ -509,7 +657,7 @@ class HindsightOpenAI:
store_conversations: bool = True, store_conversations: bool = True,
inject_memories: bool = True, inject_memories: bool = True,
max_memories: Optional[int] = None, max_memories: Optional[int] = None,
recall_budget: str = "mid", budget: str = "mid",
verbose: bool = False, verbose: bool = False,
): ):
"""Initialize the wrapped OpenAI client. """Initialize the wrapped OpenAI client.
@ -523,7 +671,7 @@ class HindsightOpenAI:
store_conversations: Whether to store conversations store_conversations: Whether to store conversations
inject_memories: Whether to inject relevant memories inject_memories: Whether to inject relevant memories
max_memories: Maximum number of memories to inject (None = no limit) 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 verbose: Enable verbose logging
""" """
self._client = client self._client = client
@ -533,7 +681,7 @@ class HindsightOpenAI:
self._store_conversations = store_conversations self._store_conversations = store_conversations
self._inject_memories = inject_memories self._inject_memories = inject_memories
self._max_memories = max_memories self._max_memories = max_memories
self._recall_budget = recall_budget self._budget = budget
self._verbose = verbose self._verbose = verbose
self._hindsight_client = None self._hindsight_client = None
@ -560,7 +708,7 @@ class HindsightOpenAI:
results = client.recall( results = client.recall(
bank_id=self._bank_id, bank_id=self._bank_id,
query=query, query=query,
budget=self._recall_budget, budget=self._budget,
max_tokens=self._max_memories * 200 if self._max_memories else 4096, max_tokens=self._max_memories * 200 if self._max_memories else 4096,
) )
@ -713,7 +861,7 @@ class HindsightAnthropic:
store_conversations: bool = True, store_conversations: bool = True,
inject_memories: bool = True, inject_memories: bool = True,
max_memories: Optional[int] = None, max_memories: Optional[int] = None,
recall_budget: str = "mid", budget: str = "mid",
verbose: bool = False, verbose: bool = False,
): ):
"""Initialize the wrapped Anthropic client. """Initialize the wrapped Anthropic client.
@ -727,7 +875,7 @@ class HindsightAnthropic:
store_conversations: Whether to store conversations store_conversations: Whether to store conversations
inject_memories: Whether to inject relevant memories inject_memories: Whether to inject relevant memories
max_memories: Maximum number of memories to inject (None = no limit) 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 verbose: Enable verbose logging
""" """
self._client = client self._client = client
@ -737,7 +885,7 @@ class HindsightAnthropic:
self._store_conversations = store_conversations self._store_conversations = store_conversations
self._inject_memories = inject_memories self._inject_memories = inject_memories
self._max_memories = max_memories self._max_memories = max_memories
self._recall_budget = recall_budget self._budget = budget
self._verbose = verbose self._verbose = verbose
self._hindsight_client = None self._hindsight_client = None
@ -764,7 +912,7 @@ class HindsightAnthropic:
results = client.recall( results = client.recall(
bank_id=self._bank_id, bank_id=self._bank_id,
query=query, query=query,
budget=self._recall_budget, budget=self._budget,
max_tokens=self._max_memories * 200 if self._max_memories else 4096, max_tokens=self._max_memories * 200 if self._max_memories else 4096,
) )
@ -889,7 +1037,7 @@ def wrap_openai(
store_conversations: bool = True, store_conversations: bool = True,
inject_memories: bool = True, inject_memories: bool = True,
max_memories: Optional[int] = None, max_memories: Optional[int] = None,
recall_budget: str = "mid", budget: str = "mid",
verbose: bool = False, verbose: bool = False,
) -> HindsightOpenAI: ) -> HindsightOpenAI:
"""Wrap an OpenAI client with Hindsight memory integration. """Wrap an OpenAI client with Hindsight memory integration.
@ -906,7 +1054,7 @@ def wrap_openai(
store_conversations: Whether to store conversations store_conversations: Whether to store conversations
inject_memories: Whether to inject relevant memories inject_memories: Whether to inject relevant memories
max_memories: Maximum number of memories to inject (None = no limit) 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 verbose: Enable verbose logging
Returns: Returns:
@ -935,7 +1083,7 @@ def wrap_openai(
store_conversations=store_conversations, store_conversations=store_conversations,
inject_memories=inject_memories, inject_memories=inject_memories,
max_memories=max_memories, max_memories=max_memories,
recall_budget=recall_budget, budget=budget,
verbose=verbose, verbose=verbose,
) )
@ -948,7 +1096,7 @@ def wrap_anthropic(
store_conversations: bool = True, store_conversations: bool = True,
inject_memories: bool = True, inject_memories: bool = True,
max_memories: Optional[int] = None, max_memories: Optional[int] = None,
recall_budget: str = "mid", budget: str = "mid",
verbose: bool = False, verbose: bool = False,
) -> HindsightAnthropic: ) -> HindsightAnthropic:
"""Wrap an Anthropic client with Hindsight memory integration. """Wrap an Anthropic client with Hindsight memory integration.
@ -965,7 +1113,7 @@ def wrap_anthropic(
store_conversations: Whether to store conversations store_conversations: Whether to store conversations
inject_memories: Whether to inject relevant memories inject_memories: Whether to inject relevant memories
max_memories: Maximum number of memories to inject (None = no limit) 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 verbose: Enable verbose logging
Returns: Returns:
@ -995,6 +1143,6 @@ def wrap_anthropic(
store_conversations=store_conversations, store_conversations=store_conversations,
inject_memories=inject_memories, inject_memories=inject_memories,
max_memories=max_memories, max_memories=max_memories,
recall_budget=recall_budget, budget=budget,
verbose=verbose, verbose=verbose,
) )

View file

@ -6,6 +6,8 @@ from typing import List, Dict, Any
from hindsight_litellm import ( from hindsight_litellm import (
configure, configure,
set_defaults,
get_defaults,
enable, enable,
disable, disable,
is_enabled, is_enabled,
@ -14,9 +16,10 @@ from hindsight_litellm import (
is_configured, is_configured,
reset_config, reset_config,
HindsightConfig, HindsightConfig,
HindsightDefaults,
MemoryInjectionMode, MemoryInjectionMode,
) )
from hindsight_litellm.callbacks import HindsightCallback, get_callback, cleanup_callback from hindsight_litellm.callbacks import HindsightCallback
class TestConfiguration: class TestConfiguration:
@ -34,47 +37,53 @@ class TestConfiguration:
def test_configure_creates_config(self): def test_configure_creates_config(self):
"""Test that configure creates a config object.""" """Test that configure creates a config object."""
config = configure( config = configure(
bank_id="test-agent",
hindsight_api_url="http://localhost:8888", 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 is not None
assert config.bank_id == "test-agent"
assert config.hindsight_api_url == "http://localhost:8888" 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): def test_configure_with_all_options(self):
"""Test configure with all options.""" """Test configure with all options."""
config = configure( config = configure(
hindsight_api_url="http://custom:9999", hindsight_api_url="http://custom:9999",
bank_id="custom-agent",
api_key="secret-key", api_key="secret-key",
store_conversations=False, store_conversations=False,
inject_memories=False, inject_memories=False,
injection_mode=MemoryInjectionMode.PREPEND_USER, 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*"], excluded_models=["gpt-3.5*"],
verbose=True, 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.hindsight_api_url == "http://custom:9999"
assert config.bank_id == "custom-agent"
assert config.api_key == "secret-key" assert config.api_key == "secret-key"
assert config.store_conversations is False assert config.store_conversations is False
assert config.inject_memories is False assert config.inject_memories is False
assert config.injection_mode == MemoryInjectionMode.PREPEND_USER 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.excluded_models == ["gpt-3.5*"]
assert config.verbose is True 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): def test_is_configured_without_bank_id(self):
"""Test is_configured returns False without bank_id.""" """Test is_configured returns False without bank_id."""
@ -83,16 +92,19 @@ class TestConfiguration:
def test_is_configured_with_bank_id(self): def test_is_configured_with_bank_id(self):
"""Test is_configured returns True with bank_id.""" """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 assert is_configured() is True
def test_reset_config(self): def test_reset_config(self):
"""Test reset_config clears the configuration.""" """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 assert is_configured() is True
reset_config() reset_config()
assert get_config() is None assert get_config() is None
assert get_defaults() is None
assert is_configured() is False assert is_configured() is False
@ -112,44 +124,42 @@ class TestEnableDisable:
with pytest.raises(RuntimeError, match="not configured"): with pytest.raises(RuntimeError, match="not configured"):
enable() enable()
def test_enable_registers_callback(self): def test_enable_without_bank_id_raises(self):
"""Test enable registers callback with LiteLLM.""" """Test enable raises error without bank_id."""
import litellm 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() enable()
callback = get_callback()
assert callback in litellm.callbacks
assert is_enabled() is True assert is_enabled() is True
def test_disable_removes_callback(self): def test_disable_clears_enabled_flag(self):
"""Test disable removes callback from LiteLLM.""" """Test disable clears the enabled flag."""
import litellm configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
configure(bank_id="test-agent")
enable() enable()
assert is_enabled() is True assert is_enabled() is True
disable() disable()
callback = get_callback()
assert callback not in litellm.callbacks
assert is_enabled() is False assert is_enabled() is False
def test_enable_idempotent(self): def test_enable_idempotent(self):
"""Test enable is idempotent (can be called multiple times).""" """Test enable is idempotent (can be called multiple times)."""
import litellm configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
configure(bank_id="test-agent")
# Enable multiple times # Enable multiple times
enable() enable()
enable() enable()
enable() enable()
# Should only have one callback # Should still be enabled
callback = get_callback() assert is_enabled() is True
assert litellm.callbacks.count(callback) == 1
class TestCallback: class TestCallback:
@ -221,14 +231,21 @@ class TestCallback:
def test_format_memories(self): def test_format_memories(self):
"""Test formatting memories into context string.""" """Test formatting memories into context string."""
callback = HindsightCallback() 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 = [ memories = [
{"text": "User likes Python", "fact_type": "world", "weight": 0.95}, {"text": "User likes Python", "fact_type": "world", "weight": 0.95},
{"text": "User works at Google", "fact_type": "world", "weight": 0.8}, {"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 "Relevant Memories" in formatted
assert "User likes Python" in formatted assert "User likes Python" in formatted
@ -238,23 +255,34 @@ class TestCallback:
def test_format_memories_with_verbose(self): def test_format_memories_with_verbose(self):
"""Test formatting memories with verbose mode shows weights.""" """Test formatting memories with verbose mode shows weights."""
callback = HindsightCallback() 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 = [ memories = [
{"text": "User likes Python", "fact_type": "world", "weight": 0.95}, {"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 assert "relevance: 0.95" in formatted
def test_inject_memories_as_system_message(self): def test_inject_memories_as_system_message(self):
"""Test injecting memories as system message.""" """Test injecting memories as system message."""
callback = HindsightCallback() callback = HindsightCallback()
config = HindsightConfig(
bank_id="test", configure(
hindsight_api_url="http://localhost:8888",
injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE, injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE,
) )
set_defaults(bank_id="test")
config = get_config()
messages = [ messages = [
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
@ -271,10 +299,14 @@ class TestCallback:
def test_inject_memories_prepend_to_existing_system(self): def test_inject_memories_prepend_to_existing_system(self):
"""Test injecting memories appends to existing system message.""" """Test injecting memories appends to existing system message."""
callback = HindsightCallback() callback = HindsightCallback()
config = HindsightConfig(
bank_id="test", configure(
hindsight_api_url="http://localhost:8888",
injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE, injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE,
) )
set_defaults(bank_id="test")
config = get_config()
messages = [ messages = [
{"role": "system", "content": "You are helpful."}, {"role": "system", "content": "You are helpful."},
@ -292,10 +324,14 @@ class TestCallback:
def test_inject_memories_prepend_user_mode(self): def test_inject_memories_prepend_user_mode(self):
"""Test injecting memories in prepend_user mode.""" """Test injecting memories in prepend_user mode."""
callback = HindsightCallback() callback = HindsightCallback()
config = HindsightConfig(
bank_id="test", configure(
hindsight_api_url="http://localhost:8888",
injection_mode=MemoryInjectionMode.PREPEND_USER, injection_mode=MemoryInjectionMode.PREPEND_USER,
) )
set_defaults(bank_id="test")
config = get_config()
messages = [ messages = [
{"role": "user", "content": "What's my name?"}, {"role": "user", "content": "What's my name?"},
@ -312,10 +348,14 @@ class TestCallback:
def test_should_skip_model_exact_match(self): def test_should_skip_model_exact_match(self):
"""Test model exclusion with exact match.""" """Test model exclusion with exact match."""
callback = HindsightCallback() callback = HindsightCallback()
config = HindsightConfig(
bank_id="test", configure(
hindsight_api_url="http://localhost:8888",
excluded_models=["gpt-3.5-turbo"], 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-3.5-turbo", config) is True
assert callback._should_skip_model("gpt-4", config) is False assert callback._should_skip_model("gpt-4", config) is False
@ -323,10 +363,14 @@ class TestCallback:
def test_should_skip_model_wildcard(self): def test_should_skip_model_wildcard(self):
"""Test model exclusion with wildcard pattern.""" """Test model exclusion with wildcard pattern."""
callback = HindsightCallback() callback = HindsightCallback()
config = HindsightConfig(
bank_id="test", configure(
hindsight_api_url="http://localhost:8888",
excluded_models=["gpt-3.5*", "claude-instant-*"], 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", config) is True
assert callback._should_skip_model("gpt-3.5-turbo-16k", 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"): with hindsight_memory(bank_id="test-agent"):
assert is_enabled() is True 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 assert is_enabled() is False
@ -423,16 +468,17 @@ class TestContextManager:
from hindsight_litellm import hindsight_memory from hindsight_litellm import hindsight_memory
# Set up initial config # Set up initial config
configure(bank_id="original-agent") configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="original-agent")
enable() enable()
assert get_config().bank_id == "original-agent" assert get_defaults().bank_id == "original-agent"
# Use context manager with different config # Use context manager with different config
with hindsight_memory(bank_id="temporary-agent"): 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 # Should restore original config
assert get_config().bank_id == "original-agent" assert get_defaults().bank_id == "original-agent"
assert is_enabled() is True assert is_enabled() is True
def test_context_manager_with_fact_types(self): def test_context_manager_with_fact_types(self):
@ -440,8 +486,8 @@ class TestContextManager:
from hindsight_litellm import hindsight_memory from hindsight_litellm import hindsight_memory
with hindsight_memory(bank_id="test-agent", fact_types=["world", "opinion"]): with hindsight_memory(bank_id="test-agent", fact_types=["world", "opinion"]):
config = get_config() defaults = get_defaults()
assert config.fact_types == ["world", "opinion"] assert defaults.fact_types == ["world", "opinion"]
class TestFactTypes: class TestFactTypes:
@ -457,15 +503,75 @@ class TestFactTypes:
def test_configure_with_fact_types(self): def test_configure_with_fact_types(self):
"""Test configuring with fact_types.""" """Test configuring with fact_types."""
config = configure( configure(hindsight_api_url="http://localhost:8888")
defaults = set_defaults(
bank_id="test-agent", bank_id="test-agent",
fact_types=["world", "agent", "opinion"], 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): def test_configure_without_fact_types(self):
"""Test configuring without fact_types defaults to None.""" """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