Compare commits

..
11 Commits
Author SHA1 Message Date
Nicolò Boschi 45ed02cd6a fix 2026-01-16 10:49:56 +01:00
Nicolò Boschi 2921fa091b fix 2026-01-16 10:21:05 +01:00
Nicolò Boschi aafa7ae17c fix ci 2026-01-16 07:52:38 +01:00
Nicolò Boschi caeab8ac44 more 2026-01-15 18:34:17 +01:00
Nicolò Boschi 42cc098a7c new style 2026-01-14 18:57:16 +01:00
Nicolò Boschi 2871ddc34c reflect agent 2026-01-14 09:29:36 +01:00
Nicolò Boschi 987b47e6a1 agentic 2026-01-14 09:29:36 +01:00
Nicolò Boschi 25fb4b9273 agentic 2026-01-14 09:29:36 +01:00
Nicolò Boschi f72c9f03fb fix db patch 2026-01-14 09:26:01 +01:00
Nicolò Boschi 517eee4eda DRAFT: refactor entity observations 2026-01-14 09:25:27 +01:00
Nicolò Boschi e19c6b9252 mental models 2026-01-14 09:25:27 +01:00
518 changed files with 19893 additions and 68953 deletions
+1 -9
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=o3-mini
@@ -13,13 +13,6 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
# Example: Google Vertex AI configuration
# HINDSIGHT_API_LLM_PROVIDER=vertexai
# HINDSIGHT_API_LLM_MODEL=google/gemini-2.0-flash-001
# HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-gcp-project-id
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
@@ -33,7 +26,6 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
+1 -58
View File
@@ -139,55 +139,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclaw-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/openclaw
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/openclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: openclaw-integration
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -415,7 +366,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -438,12 +389,6 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v4
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
@@ -485,8 +430,6 @@ jobs:
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+29 -116
View File
@@ -82,29 +82,6 @@ jobs:
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
build-openclaw-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/openclaw
run: npm test
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
build-control-plane:
runs-on: ubuntu-latest
@@ -176,15 +153,8 @@ jobs:
- name: Build docs
run: npm run build --workspace=hindsight-docs
test-rust-cli:
build-rust-cli:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
@@ -201,10 +171,6 @@ jobs:
hindsight-cli/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Run unit tests
working-directory: hindsight-cli
run: cargo test
- name: Build CLI
working-directory: hindsight-cli
run: cargo build --release
@@ -216,6 +182,29 @@ jobs:
path: hindsight-cli/target/release/hindsight
retention-days: 1
test-rust-cli:
runs-on: ubuntu-latest
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: hindsight-cli
path: /tmp/cli
- name: Make CLI executable
run: chmod +x /tmp/cli/hindsight
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
@@ -262,7 +251,7 @@ jobs:
- name: Run CLI smoke test
run: |
HINDSIGHT_CLI=hindsight-cli/target/release/hindsight ./hindsight-cli/smoke-test.sh
HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh
- name: Show API server logs
if: always()
@@ -749,9 +738,9 @@ jobs:
test-embed:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_EMBED_LLM_PROVIDER: groq
HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
@@ -782,17 +771,13 @@ jobs:
${{ runner.os }}-huggingface-embed-
${{ runner.os }}-huggingface-
- name: Run unit and integration tests
working-directory: ./hindsight-embed
run: uv run pytest tests/ -v
- name: Run smoke test
working-directory: ./hindsight-embed
run: ./test.sh
test-doc-examples:
runs-on: ubuntu-latest
needs: test-rust-cli
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
@@ -902,78 +887,6 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-upgrade:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for git clone of tags
- name: Fetch tags
run: git fetch --tags
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Install hindsight-dev dependencies
working-directory: ./hindsight-dev
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Install current hindsight-api
working-directory: ./hindsight-api
run: uv sync --frozen --index-strategy unsafe-best-match
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Run upgrade tests
working-directory: ./hindsight-dev
run: uv run pytest upgrade_tests/ -v --tb=short
- name: Show upgrade test logs
if: always()
run: |
echo "=== Upgrade Test Server Logs ==="
for log in /tmp/upgrade-test-*.log; do
if [ -f "$log" ]; then
echo ""
echo "--- $log ---"
tail -500 "$log"
fi
done
verify-generated-files:
runs-on: ubuntu-latest
env:
+2 -5
View File
@@ -29,7 +29,7 @@ nltk_data/
# Monitoring stack (Prometheus/Grafana binaries and data)
.monitoring/
.pgbouncer/
.pgbouncer
# Large benchmark datasets (will be downloaded automatically)
**/longmemeval_s_cleaned.json
@@ -45,12 +45,9 @@ hindsight-docs/static/llms-full.txt
hindsight-dev/benchmarks/locomo/results/
hindsight-dev/benchmarks/longmemeval/results/
hindsight-dev/benchmarks/consolidation/results/
benchmarks/results/
hindsight-cli/target
hindsight-clients/rust/target
.claude
whats-next.md
TASK.md
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
CHANGELOG.md
+39
View File
@@ -0,0 +1,39 @@
[databases]
; Connect to pg0 on port 5433
; The actual pg0 database is called "hindsight"
hindsight = host=127.0.0.1 port=5433 dbname=hindsight user=hindsight password=hindsight
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
; Use md5 authentication (matches pg0's auth)
auth_type = md5
auth_file = /Users/nicoloboschi/dev/memory-poc/.pgbouncer/userlist.txt
; Transaction pooling mode (recommended for hindsight)
pool_mode = transaction
; Reset connection state after each transaction
server_reset_query = DISCARD ALL
; Pool sizing
default_pool_size = 20
max_client_conn = 200
min_pool_size = 5
; Timeouts
server_idle_timeout = 600
server_lifetime = 3600
query_timeout = 120
; Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
; Stats
stats_period = 60
; Admin console
admin_users = admin
+2
View File
@@ -0,0 +1,2 @@
"hindsight" "md5d842ccb6249bcd3c53b2f648378092a6"
"admin" ""
+151 -1
View File
@@ -1,3 +1,153 @@
# AGENTS.md
See [CLAUDE.md](./CLAUDE.md) for project documentation and coding conventions.
This document captures architectural decisions and coding conventions for the Hindsight project.
## Documentation
- **Main documentation**: [hindsight-docs/docs/developer/](./hindsight-docs/docs/developer/)
- **Use case patterns**: [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/)
- **API reference**: Auto-generated from OpenAPI spec
## Project Structure
```
hindsight/ # Python package for embedded usage
hindsight-api/ # FastAPI server (core memory engine)
hindsight-cli/ # Rust CLI client
hindsight-embed/ # Embedded CLI (no server needed)
hindsight-control-plane/ # Next.js admin UI
hindsight-docs/ # Docusaurus documentation site
hindsight-dev/ # Development tools and benchmarks
hindsight-integrations/ # Framework integrations (LangChain, etc.)
hindsight-clients/ # Generated API clients (Python, TypeScript, Rust)
```
## Core Concepts
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
- Banks contain: memory units (facts), entities, documents, entity links
- Banks have a **disposition** (personality traits) and **background** (context)
- Bank isolation is strict - no cross-bank data leakage
### Memory Types
- **World facts**: General knowledge ("The sky is blue")
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
### Operations
- **Retain**: Store new memories (extracts facts, entities, relationships)
- **Recall**: Retrieve memories (semantic, BM25, graph, temporal search)
- **Reflect**: Deep analysis to form new insights/opinions
## API Design Decisions
### Single Bank Per Request
- All API endpoints (`recall`, `reflect`, `retain`) operate on a single bank
- Multi-bank queries are the **client/agent's responsibility** to orchestrate
- This keeps the API simple and the isolation model clear
### Disposition Traits (3-trait system)
- **Skepticism** (1-5): How skeptical vs trusting when forming opinions
- **Literalism** (1-5): How literally to interpret information
- **Empathy** (1-5): How much to consider emotional context
- These influence the `reflect` operation, not `recall`
- Background info also only affects `reflect` (opinion formation)
## Multi-Bank Architecture Patterns
See [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) for detailed guides:
- **Per-User Memory**: One bank per user, simplest pattern
- **Support Agent + Shared Knowledge**: User bank + shared docs bank, client orchestrates
## Developer Guide
### Running the API Server
```bash
# From project root
./scripts/dev/start-api.sh
# With options
./scripts/dev/start-api.sh --reload --port 8888 --log-level debug
```
### Running Tests
```bash
# API tests
cd hindsight-api
uv run pytest tests/
# Specific test
uv run pytest tests/test_http_api_integration.py -v
```
### Generating OpenAPI Spec
After changing API endpoints, regenerate the OpenAPI spec and docs:
```bash
./scripts/generate-openapi.sh
```
This will:
1. Generate `openapi.json` at project root
2. Copy to `hindsight-docs/openapi.json`
3. Regenerate API reference documentation
### Generating API Clients
After updating the OpenAPI spec, regenerate all clients:
```bash
./scripts/generate-clients.sh
```
This generates:
- **Rust client**: `hindsight-clients/rust/` (via progenitor in build.rs)
- **Python client**: `hindsight-clients/python/` (via openapi-generator Docker)
- **TypeScript client**: `hindsight-clients/typescript/` (via @hey-api/openapi-ts)
Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved during regeneration.
### Running the Documentation Site
```bash
./scripts/dev/start-docs.sh
```
### Running the Control Plane
```bash
./scripts/dev/start-control-plane.sh
```
## Code Style
### Python (hindsight-api)
- Use `uv` for package management
- Async throughout (asyncpg, async FastAPI endpoints)
- Pydantic models for request/response validation
- No py files at project root - maintain clean directory structure
### TypeScript (control-plane, clients)
- Next.js with App Router for control plane
- Tailwind CSS with shadcn/ui components
### Rust (CLI)
- Async with tokio
- reqwest for HTTP client
- progenitor for API client generation
## Database
- PostgreSQL with pgvector extension
- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands
- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
# Branding
## Colors
- Primary: gradient from #0074d9 to #009296
+3 -34
View File
@@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. Memories are organized as:
- **World facts**: General knowledge ("The sky is blue")
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
- **Mental models**: Consolidated knowledge synthesized from facts ("User prefers functional programming patterns")
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
- **Observations**: Complex mental models derived from reflection
## Development Commands
@@ -100,7 +101,7 @@ cd hindsight-control-plane && npm run dev
Main operations:
- **Retain**: Store memories, extracts facts/entities/relationships
- **Recall**: Retrieve memories via 4 parallel strategies (semantic, BM25, graph, temporal) + reranking
- **Reflect**: Disposition-aware reasoning using memories and mental models.
- **Reflect**: Deep analysis forming new opinions/observations (disposition-aware)
### Database
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
@@ -198,38 +199,6 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
- Pydantic models for request/response
- Ruff for linting (line-length 120)
- No Python files at project root - maintain clean directory structure
- **Never use multi-item tuple return values** - prefer dataclass or Pydantic model for structured returns
### Type Safety with Pydantic Models
**NEVER use raw `dict` types for structured data.** Always use Pydantic models:
- Use Pydantic `BaseModel` for all data structures passed between functions
- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware)
- Avoid `dict.get()` patterns - use typed model attributes instead
- Parse external data (JSON, API responses) into Pydantic models at the boundary
- This catches type errors at parse time, not deep in business logic
```python
# BAD - error-prone dict access
def process(data: dict) -> str:
return data.get("name", "") # No validation, silent failures
# GOOD - typed and validated
class UserData(BaseModel):
name: str
created_at: datetime
@field_validator("created_at", mode="before")
@classmethod
def ensure_tz_aware(cls, v):
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
def process(data: UserData) -> str:
return data.name # Type-safe, validated at construction
```
### TypeScript Style
- Next.js App Router for control plane
-28
View File
@@ -93,34 +93,6 @@ uv run ty check hindsight_api # Type check
3. Run tests to ensure nothing breaks
4. Submit a PR with a clear description of changes
## Release Process
The project uses `scripts/release.sh` for creating releases. This script automates the entire release workflow:
1. Bumps version in all components (API, clients, CLI, control plane, Helm)
2. **Regenerates OpenAPI spec and client SDKs** (Python, TypeScript, Rust)
3. Updates documentation versioning
4. Creates a commit and git tag
5. Pushes to GitHub (triggers CI/CD to publish packages)
### Usage
```bash
./scripts/release.sh <version>
```
**Example:**
```bash
./scripts/release.sh 0.5.0
```
### Important for Developers
- During development, version bumps in `__init__.py` do NOT require client regeneration
- Clients are only regenerated during releases
- Do not manually run `./scripts/generate-clients.sh` unless testing generation changes
- Client version comments will reflect the API version from the latest release
## Reporting Issues
Open an issue on GitHub with:
+40 -53
View File
@@ -1,6 +1,6 @@
<div align="center">
![Hindsight Banner](./hindsight-docs/static/img/hindsight-github-banner.png)
![Hindsight Banner](./hindsight-docs/static/img/banner.svg)
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://vectorize.io/hindsight/cloud)
@@ -17,31 +17,55 @@
## What is Hindsight?
Hindsight™ is an agent memory system built to create smarter agents that learn over time. Most agent memory systems focus on recalling conversation history. Hindsight is focused on making agents that learn, not just remember.
Hindsight™ is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
<video src="https://github.com/user-attachments/assets/923b798d-3581-4897-bb62-9cfa5a931682" controls></video>
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
## How is Hindsight Different From Other Memory Systems?
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
- **World:** Facts about the world ("The stove gets hot")
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
Hindsight provides three simple methods to interact with the system:
- **Retain:** Provide information to Hindsight that you want it to remember
- **Recall:** Retrieve memories from Hindsight
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
### Agent Memory That Learns
A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time.
For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience.
As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task.
---
## Memory Performance & Accuracy
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
Hindsight has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational
AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of December 2025 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
The benchmark performance data for Hindsight and GPT-4o (full context) have been reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.
## Adding Hindsight to Your AI Agents
The easiest way use Hindsight with an existing agent is with the LLM Wrapper. You can add memory to your agent with 2 lines of code. That will swap your current LLM client out with the Hindsight wrapper. After that, memories will be stored and retrieved automatically as you make LLM calls.
If you need more control over how and when your agent stores and recalls memories, there's also a simple API you can integrate with using the SDKs or directly via HTTP.
![Hindsight Banner](./hindsight-docs/static/img/migration-code.png)
A thorough examination of the techniques implemented in Hindsight and detailed breakdowns of benchmark performance are [available on arXiv](https://arxiv.org/abs/2512.12818). This research is currently being prepared for conference submission and the wider peer review process.
The benchmark results from this research can be inspected in our [visual benchmark explorer](https://hindsight-benchmarks.vercel.app). As additional improvements are made to Hindsight, new benchmark data will be available for review using this same tool.
## Quick Start
@@ -124,45 +148,8 @@ await client.recall('my-bank', 'What does Alice like?');
---
## Use Cases
Hindsight is built to support conversational AI agents as well as agents that are intended to perform tasks autonomously. The ideal use case for Hindsight are agents that require a blend of these features such as AI employees that need to handle open-ended tasks, change behavior based on user feedback, and learn to perform complex tasks to automate work at a level that approximates a human work. Hindsight can be used with simple AI workflows like those built with n8n and other similar tools, but may be overkill for such applications.
### Per-User Memories and Chat History
One of the simpler use cases you can use Hindsight for is to personalize AI chatbots and other conversational agents by storing and recalling memories associated with individual users.
The requirements for this use case usually look something like this:
![Per-User Memories](./hindsight-docs/static/img/per-user-memory-requirements.png)
<video src="https://github.com/user-attachments/assets/4805e8e1-e7d1-47c6-a4f8-2344a5ec8906" controls></video>
Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.
![Per-User Memories](./hindsight-docs/static/img/per-user-memory-howto.png)
---
## Architecture & Operations
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
- **World:** Facts about the world ("The stove gets hot")
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
- **Mental Models:** Learned understanding of the agent's world formed by reflecting on raw memories and experiences.
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
Hindsight provides three simple methods to interact with the system:
- **Retain:** Provide information to Hindsight that you want it to remember
- **Recall:** Retrieve memories from Hindsight
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
### Retain
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
@@ -221,7 +208,7 @@ The final output is trimmed as needed to fit within the token limit.
### Reflect
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world.
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences.
For example, the `reflect` operation can be used to support use cases such as:
+4 -40
View File
@@ -169,34 +169,16 @@ ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
ARG PRELOAD_ML_MODELS
ARG INCLUDE_LOCAL_MODELS
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN if [ "$PRELOAD_ML_MODELS" = "true" ] && [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
MAX_RETRIES=3; \
RETRY_DELAY=10; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading ML models..."; \
/app/api/.venv/bin/python -c "\
import os; os.environ['HF_HUB_DOWNLOAD_TIMEOUT'] = '600'; \
/app/api/.venv/bin/python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ] && ! /app/api/.venv/bin/python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-en-v1.5')" 2>/dev/null; then \
echo "ERROR: Failed to download models after $MAX_RETRIES attempts"; \
exit 1; \
fi; \
print('Models cached successfully')"; \
elif [ "$INCLUDE_LOCAL_MODELS" != "true" ]; then echo "Skipping ML model preload (local-models not included)"; \
else echo "Skipping ML model preload"; fi
@@ -295,34 +277,16 @@ ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
ARG PRELOAD_ML_MODELS
ARG INCLUDE_LOCAL_MODELS
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN if [ "$PRELOAD_ML_MODELS" = "true" ] && [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
MAX_RETRIES=3; \
RETRY_DELAY=10; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading ML models..."; \
/app/api/.venv/bin/python -c "\
import os; os.environ['HF_HUB_DOWNLOAD_TIMEOUT'] = '600'; \
/app/api/.venv/bin/python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ] && ! /app/api/.venv/bin/python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-en-v1.5')" 2>/dev/null; then \
echo "ERROR: Failed to download models after $MAX_RETRIES attempts"; \
exit 1; \
fi; \
print('Models cached successfully')"; \
elif [ "$INCLUDE_LOCAL_MODELS" != "true" ]; then echo "Skipping ML model preload (local-models not included)"; \
else echo "Skipping ML model preload"; fi
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.7
appVersion: "0.4.7"
version: 0.3.0
appVersion: "0.3.0"
keywords:
- ai
- memory
-16
View File
@@ -80,22 +80,6 @@ Control plane selector labels
app.kubernetes.io/component: control-plane
{{- end }}
{{/*
Worker labels
*/}}
{{- define "hindsight.worker.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: worker
{{- end }}
{{/*
Worker selector labels
*/}}
{{- define "hindsight.worker.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: worker
{{- end }}
{{/*
Create the name of the service account to use
*/}}
@@ -55,11 +55,6 @@ spec:
{{- end }}
- name: HINDSIGHT_API_DATABASE_URL
value: {{ include "hindsight.databaseUrl" . | quote }}
{{- /* Disable internal worker when dedicated workers are enabled */}}
{{- if .Values.worker.enabled }}
- name: HINDSIGHT_API_WORKER_ENABLED
value: "false"
{{- end }}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
@@ -1,25 +0,0 @@
{{- if .Values.worker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-worker
labels:
{{- include "hindsight.worker.labels" . | nindent 4 }}
{{- if .Values.podAnnotations }}
annotations:
{{- /* Common Prometheus annotations for metrics scraping */}}
prometheus.io/scrape: "true"
prometheus.io/port: {{ .Values.worker.service.port | quote }}
prometheus.io/path: "/metrics"
{{- end }}
spec:
# Headless service for StatefulSet (enables stable DNS names like worker-0.worker.namespace)
clusterIP: None
ports:
- port: {{ .Values.worker.service.port }}
targetPort: {{ .Values.worker.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "hindsight.worker.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -1,110 +0,0 @@
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "hindsight.fullname" . }}-worker
labels:
{{- include "hindsight.worker.labels" . | nindent 4 }}
spec:
serviceName: {{ include "hindsight.fullname" . }}-worker
replicas: {{ .Values.worker.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.worker.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
{{- if not .Values.existingSecret }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.worker.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: worker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version }}"
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
command: ["hindsight-worker"]
ports:
- name: http
containerPort: {{ .Values.worker.service.targetPort }}
protocol: TCP
{{- if .Values.existingSecret }}
envFrom:
- secretRef:
name: {{ .Values.existingSecret }}
{{- end }}
env:
{{- /* POSTGRES_PASSWORD must be defined before DATABASE_URL for $(VAR) interpolation */}}
{{- if not .Values.postgresql.enabled }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "hindsight.secretName" . }}
key: postgres-password
{{- end }}
- name: HINDSIGHT_API_DATABASE_URL
value: {{ include "hindsight.databaseUrl" . | quote }}
{{- /* Worker ID uses pod name (StatefulSet provides stable names like worker-0, worker-1) */}}
- name: HINDSIGHT_API_WORKER_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
{{- /* Inherit LLM config from api.env */}}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- /* Worker-specific env vars */}}
{{- range $key, $value := .Values.worker.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- /* Only use secrets when not using existingSecret */}}
{{- if not .Values.existingSecret }}
{{- /* Inherit secrets from api.secrets */}}
{{- range $key, $value := .Values.api.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "hindsight.secretName" $ }}
key: {{ $key }}
{{- end }}
{{- /* Worker-specific secrets (can override api.secrets) */}}
{{- range $key, $value := .Values.worker.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "hindsight.secretName" $ }}
key: {{ $key }}
{{- end }}
{{- end }}
livenessProbe:
{{- toYaml .Values.worker.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.worker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.worker.resources | nindent 10 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
-57
View File
@@ -67,63 +67,6 @@ api:
# HINDSIGHT_API_LLM_API_KEY: "your-api-key"
# HINDSIGHT_API_LLM_BASE_URL: "https://api.groq.com/openai/v1"
# Worker settings (distributed task processing)
# When enabled, dedicated worker pods process tasks and the API's internal worker is disabled
worker:
enabled: false
replicaCount: 2
image:
repository: ghcr.io/vectorize-io/hindsight-api
pullPolicy: IfNotPresent
# tag defaults to .Values.version if not specified
service:
# Service for metrics scraping (headless for StatefulSet)
port: 8889
targetPort: 8889
# Resource limits and requests
resources:
limits:
cpu: 2000m
memory: 4Gi
requests:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
livenessProbe:
httpGet:
path: /health
port: 8889
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8889
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Worker-specific environment variables
env:
# Poll interval in milliseconds (how often to check for new tasks)
HINDSIGHT_API_WORKER_POLL_INTERVAL_MS: "500"
# Number of tasks to claim per poll cycle
HINDSIGHT_API_WORKER_BATCH_SIZE: "10"
# Max retries before marking a task as failed
HINDSIGHT_API_WORKER_MAX_RETRIES: "3"
# HTTP port for metrics/health (matches service.targetPort)
HINDSIGHT_API_WORKER_HTTP_PORT: "8889"
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
# Image settings for control plane
controlPlane:
enabled: true
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.7"
__version__ = "0.1.0"
-59
View File
@@ -244,65 +244,6 @@ def run_db_migration(
typer.echo("Database migrations completed successfully")
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
"""Release all tasks owned by a worker, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
conn = await asyncpg.connect(resolved_url)
try:
table = _fq_table("async_operations", schema)
result = await conn.fetch(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE worker_id = $1 AND status = 'processing'
RETURNING operation_id
""",
worker_id,
)
return len(result)
finally:
await conn.close()
@app.command(name="decommission-worker")
def decommission_worker(
worker_id: str = typer.Argument(..., help="Worker ID to decommission"),
schema: str = typer.Option("public", "--schema", "-s", help="Database schema"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Release all tasks owned by a worker (sets status back to pending).
Use this command when a worker has crashed or been removed without graceful shutdown.
All tasks that were being processed by the worker will be released back to the queue
so other workers can pick them up.
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
if not yes:
typer.confirm(
f"This will release all tasks owned by worker '{worker_id}' back to pending. Continue?",
abort=True,
)
typer.echo(f"Decommissioning worker '{worker_id}' (schema: {schema})...")
count = asyncio.run(_decommission_worker(config.database_url, worker_id, schema))
if count > 0:
typer.echo(f"Released {count} task(s) from worker '{worker_id}'")
else:
typer.echo(f"No tasks found for worker '{worker_id}'")
def main():
app()
@@ -11,7 +11,6 @@ from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
@@ -24,21 +23,8 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema - create all tables from scratch."""
# Note: pgvector extension is installed globally BEFORE migrations run
# See migrations.py:run_migrations() - this ensures the extension is available
# to all schemas, not just the one being migrated
# We keep this here as a fallback for backwards compatibility
# This may fail if user lacks permissions, which is fine if extension already exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
# Enable required extensions
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# Create banks table
op.create_table(
@@ -1,95 +0,0 @@
"""mental_model_versions
Revision ID: j5e6f7g8h9i0
Revises: i4d5e6f7g8h9
Create Date: 2026-01-16 00:00:00.000000
This migration adds versioning support for mental models:
1. Creates mental_model_versions table to store observation snapshots
2. Adds version column to mental_models for tracking current version
This enables changelog/diff functionality for mental model observations.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "j5e6f7g8h9i0"
down_revision: str | Sequence[str] | None = "i4d5e6f7g8h9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Create mental_model_versions table and add version tracking."""
schema = _get_schema_prefix()
# Create mental_model_versions table for storing observation snapshots
op.execute(f"""
CREATE TABLE {schema}mental_model_versions (
id SERIAL PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
version INT NOT NULL,
observations JSONB NOT NULL DEFAULT '{{"observations": []}}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE,
UNIQUE (mental_model_id, bank_id, version)
)
""")
# Index for efficient version queries (get latest, list versions)
op.execute(f"""
CREATE INDEX idx_mental_model_versions_lookup
ON {schema}mental_model_versions(mental_model_id, bank_id, version DESC)
""")
# Add version column to mental_models to track current version
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 0
""")
# Migrate existing mental models: create version 1 for any that have observations
op.execute(f"""
INSERT INTO {schema}mental_model_versions (mental_model_id, bank_id, version, observations, created_at)
SELECT id, bank_id, 1, observations, COALESCE(last_updated, created_at)
FROM {schema}mental_models
WHERE observations IS NOT NULL
AND observations != '{{"observations": []}}'::jsonb
AND (observations->'observations') IS NOT NULL
AND jsonb_array_length(observations->'observations') > 0
""")
# Update version to 1 for migrated mental models
op.execute(f"""
UPDATE {schema}mental_models
SET version = 1
WHERE observations IS NOT NULL
AND observations != '{{"observations": []}}'::jsonb
AND (observations->'observations') IS NOT NULL
AND jsonb_array_length(observations->'observations') > 0
""")
def downgrade() -> None:
"""Remove mental_model_versions table and version column."""
schema = _get_schema_prefix()
# Drop index
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mental_model_versions_lookup")
# Drop versions table
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions")
# Remove version column from mental_models
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS version")
@@ -1,58 +0,0 @@
"""add_directive_subtype
Revision ID: k6f7g8h9i0j1
Revises: j5e6f7g8h9i0
Create Date: 2026-01-16 00:00:00.000000
This migration adds 'directive' to the mental_models subtype constraint.
Directives are hard rules with user-provided observations that the reflect agent must follow.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "k6f7g8h9i0j1"
down_revision: str | Sequence[str] | None = "j5e6f7g8h9i0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add 'directive' to mental_models subtype constraint."""
schema = _get_schema_prefix()
# Drop existing constraint
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
# Create new constraint with 'directive' added
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned', 'directive'))
""")
def downgrade() -> None:
"""Remove 'directive' from mental_models subtype constraint."""
schema = _get_schema_prefix()
# First delete any directives (cannot downgrade if they exist)
op.execute(f"DELETE FROM {schema}mental_models WHERE subtype = 'directive'")
# Drop constraint with directive
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
# Recreate original constraint without directive
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'))
""")
@@ -1,109 +0,0 @@
"""add_worker_columns
Revision ID: l7g8h9i0j1k2
Revises: k6f7g8h9i0j1
Create Date: 2026-01-19 00:00:00.000000
This migration adds columns to async_operations for distributed worker support:
- worker_id: ID of the worker that claimed the task
- claimed_at: When the task was claimed
- retry_count: Number of retry attempts
- task_payload: The serialized task dictionary
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "l7g8h9i0j1k2"
down_revision: str | Sequence[str] | None = "k6f7g8h9i0j1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add worker columns to async_operations."""
schema = _get_schema_prefix()
# Add worker_id column (ID of worker that claimed the task)
op.add_column(
"async_operations",
sa.Column("worker_id", sa.Text(), nullable=True),
schema=context.config.get_main_option("target_schema") or None,
)
# Add claimed_at column (when task was claimed by worker)
op.add_column(
"async_operations",
sa.Column("claimed_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
schema=context.config.get_main_option("target_schema") or None,
)
# Add retry_count column (number of retry attempts)
op.add_column(
"async_operations",
sa.Column("retry_count", sa.Integer(), server_default="0", nullable=False),
schema=context.config.get_main_option("target_schema") or None,
)
# Add task_payload column (serialized task dictionary)
op.add_column(
"async_operations",
sa.Column(
"task_payload",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
schema=context.config.get_main_option("target_schema") or None,
)
# Add index for efficient worker polling (pending tasks ordered by creation time)
op.execute(
f"CREATE INDEX idx_async_operations_pending_claim ON {schema}async_operations (status, created_at) "
f"WHERE status = 'pending' AND task_payload IS NOT NULL"
)
# Add index for finding tasks by worker_id (for decommissioning)
op.execute(
f"CREATE INDEX idx_async_operations_worker_id ON {schema}async_operations (worker_id) WHERE worker_id IS NOT NULL"
)
def downgrade() -> None:
"""Remove worker columns from async_operations."""
schema = _get_schema_prefix()
# Drop indexes
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_pending_claim")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_worker_id")
# Drop columns
op.drop_column(
"async_operations",
"task_payload",
schema=context.config.get_main_option("target_schema") or None,
)
op.drop_column(
"async_operations",
"retry_count",
schema=context.config.get_main_option("target_schema") or None,
)
op.drop_column(
"async_operations",
"claimed_at",
schema=context.config.get_main_option("target_schema") or None,
)
op.drop_column(
"async_operations",
"worker_id",
schema=context.config.get_main_option("target_schema") or None,
)
@@ -1,41 +0,0 @@
"""mental_model_id_to_text
Revision ID: m8h9i0j1k2l3
Revises: l7g8h9i0j1k2
Create Date: 2026-01-19 00:00:00.000000
This migration changes the mental_models.id column from VARCHAR(64) to TEXT
to support longer model IDs (e.g., entity names that exceed 64 characters).
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "m8h9i0j1k2l3"
down_revision: str | Sequence[str] | None = "l7g8h9i0j1k2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Change mental_models.id from VARCHAR(64) to TEXT."""
schema = _get_schema_prefix()
# Alter the id column type from VARCHAR(64) to TEXT
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT")
def downgrade() -> None:
"""Revert mental_models.id from TEXT to VARCHAR(64)."""
schema = _get_schema_prefix()
# Note: This may fail if any id values exceed 64 characters
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE VARCHAR(64)")
@@ -1,134 +0,0 @@
"""learnings_and_pinned_reflections
Revision ID: n9i0j1k2l3m4
Revises: m8h9i0j1k2l3
Create Date: 2026-01-21 00:00:00.000000
This migration:
1. Creates the 'learnings' table for automatic bottom-up consolidation
2. Creates the 'pinned_reflections' table for user-curated living documents
3. Adds consolidation tracking columns to the 'banks' table
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "n9i0j1k2l3m4"
down_revision: str | Sequence[str] | None = "m8h9i0j1k2l3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Create learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# 1. Create learnings table
op.execute(f"""
CREATE TABLE {schema}learnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
text TEXT NOT NULL,
proof_count INT NOT NULL DEFAULT 1,
history JSONB DEFAULT '[]'::jsonb,
mission_context VARCHAR(64),
pre_mission_change BOOLEAN DEFAULT FALSE,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key constraint
op.execute(f"""
ALTER TABLE {schema}learnings
ADD CONSTRAINT fk_learnings_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Indexes for learnings
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
# Full-text search for learnings
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
""")
op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
# 2. Create pinned_reflections table
op.execute(f"""
CREATE TABLE {schema}pinned_reflections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
source_query TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
last_refreshed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key constraint
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
ADD CONSTRAINT fk_pinned_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Indexes for pinned_reflections
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
# Full-text search for pinned_reflections
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING gin(search_vector)
""")
# 3. Add consolidation tracking columns to banks table
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN IF NOT EXISTS last_consolidated_at TIMESTAMP WITH TIME ZONE
""")
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN IF NOT EXISTS mission_changed_at TIMESTAMP WITH TIME ZONE
""")
def downgrade() -> None:
"""Drop learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# Drop tables
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
op.execute(f"DROP TABLE IF EXISTS {schema}pinned_reflections CASCADE")
# Remove columns from banks
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS last_consolidated_at")
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission_changed_at")
@@ -1,113 +0,0 @@
"""migrate_mental_models_data
Revision ID: o0j1k2l3m4n5
Revises: n9i0j1k2l3m4
Create Date: 2026-01-21 00:00:00.000000
This migration:
1. Migrates existing 'pinned' mental models to the new 'pinned_reflections' table
2. Migrates existing 'learned' mental models to the new 'learnings' table
3. Deletes non-directive mental models (structural, emergent, pinned, learned)
4. Drops the mental_model_versions table (no longer used)
5. Adds a CHECK constraint that only 'directive' subtype is allowed
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "o0j1k2l3m4n5"
down_revision: str | Sequence[str] | None = "n9i0j1k2l3m4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Migrate data and clean up old mental models."""
schema = _get_schema_prefix()
# 1. Migrate 'pinned' mental models to pinned_reflections
# For pinned models, the first observation's content becomes the pinned reflection content
op.execute(f"""
INSERT INTO {schema}pinned_reflections (bank_id, name, source_query, content, tags, created_at)
SELECT
bank_id,
name,
description AS source_query,
COALESCE(
observations->'observations'->0->>'content',
description,
''
) AS content,
tags,
created_at
FROM {schema}mental_models
WHERE subtype = 'pinned'
ON CONFLICT DO NOTHING
""")
# 2. Migrate 'learned' mental models to learnings
# Each observation in a learned model becomes a separate learning
op.execute(f"""
INSERT INTO {schema}learnings (bank_id, text, proof_count, tags, created_at)
SELECT
mm.bank_id,
obs->>'content' AS text,
GREATEST(1, COALESCE(jsonb_array_length(obs->'evidence'), 1)) AS proof_count,
mm.tags,
mm.created_at
FROM {schema}mental_models mm,
LATERAL jsonb_array_elements(mm.observations->'observations') AS obs
WHERE mm.subtype = 'learned'
AND obs->>'content' IS NOT NULL
AND obs->>'content' != ''
ON CONFLICT DO NOTHING
""")
# 3. Delete all non-directive mental models (they've been migrated or are obsolete)
op.execute(f"""
DELETE FROM {schema}mental_models
WHERE subtype != 'directive'
""")
# 4. Drop the mental_model_versions table (no longer used)
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions CASCADE")
# 5. Drop old constraints and add new one that only allows 'directive'
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype CHECK (subtype = 'directive')
""")
def downgrade() -> None:
"""Reverse the migration (data migration is one-way, so this just removes constraints)."""
schema = _get_schema_prefix()
# Remove the directive-only constraint
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
# Re-create mental_model_versions table
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}mental_model_versions (
id SERIAL PRIMARY KEY,
bank_id VARCHAR(64) NOT NULL,
model_id VARCHAR(128) NOT NULL,
version INT NOT NULL,
observations JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_mm_versions_lookup ON {schema}mental_model_versions(bank_id, model_id, version DESC)"
)
# Note: Data migration cannot be reversed - pinned_reflections and learnings data remains
@@ -1,194 +0,0 @@
"""new_knowledge_architecture
Revision ID: p1k2l3m4n5o6
Revises: o0j1k2l3m4n5
Create Date: 2026-01-21 00:00:00.000000
This migration implements the new knowledge architecture:
1. Drops the 'learnings' table (mental models are now in memory_units)
2. Renames 'pinned_reflections' to 'reflections'
3. Drops the 'mental_models' table completely
4. Creates 'directives' table for hard rules
5. Adds mental model support columns to 'memory_units' (proof_count, source_memory_ids, history)
The new architecture:
- Directives: Hard rules in their own table
- Mental Models: Stored in memory_units with fact_type='mental_model'
- Reflections: User-curated documents (renamed from pinned_reflections)
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "p1k2l3m4n5o6"
down_revision: str | Sequence[str] | None = "o0j1k2l3m4n5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Implement new knowledge architecture."""
schema = _get_schema_prefix()
# 1. Drop the learnings table (mental models will be in memory_units)
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
# 2. Rename pinned_reflections to reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}pinned_reflections RENAME TO reflections")
# Rename indexes for reflections
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_bank_id RENAME TO idx_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_embedding RENAME TO idx_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_tags RENAME TO idx_reflections_tags")
op.execute(
f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_text_search RENAME TO idx_reflections_text_search"
)
# Rename foreign key constraint
op.execute(f"""
ALTER TABLE {schema}reflections
DROP CONSTRAINT IF EXISTS fk_pinned_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}reflections
ADD CONSTRAINT fk_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# 3. Drop the mental_models table completely
op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
# 4. Create directives table
op.execute(f"""
CREATE TABLE {schema}directives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
content TEXT NOT NULL,
priority INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key and indexes for directives
op.execute(f"""
ALTER TABLE {schema}directives
ADD CONSTRAINT fk_directives_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
op.execute(f"CREATE INDEX idx_directives_bank_id ON {schema}directives(bank_id)")
op.execute(f"CREATE INDEX idx_directives_bank_active ON {schema}directives(bank_id, is_active)")
op.execute(f"CREATE INDEX idx_directives_tags ON {schema}directives USING GIN(tags)")
# 5. Add mental model support columns to memory_units
# proof_count: Number of memories that support this mental model
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS proof_count INT DEFAULT 1
""")
# source_memory_ids: Array of memory IDs that consolidated into this mental model
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] DEFAULT ARRAY[]::UUID[]
""")
# history: JSONB array tracking changes to mental models
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb
""")
# Add index for finding mental models
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'mental_model'
""")
# 6. Update fact_type check constraint to include 'mental_model'
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
def downgrade() -> None:
"""Reverse the migration."""
schema = _get_schema_prefix()
# Restore original fact_type check constraint (without 'mental_model')
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
# Drop mental model columns from memory_units
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS proof_count")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS source_memory_ids")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
# Drop directives table
op.execute(f"DROP TABLE IF EXISTS {schema}directives CASCADE")
# Rename reflections back to pinned_reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO pinned_reflections")
# Restore indexes
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_pinned_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_pinned_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_pinned_reflections_tags")
op.execute(
f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_pinned_reflections_text_search"
)
# Restore foreign key
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
ADD CONSTRAINT fk_pinned_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Re-create learnings table
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}learnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
text TEXT NOT NULL,
proof_count INT NOT NULL DEFAULT 1,
history JSONB DEFAULT '[]'::jsonb,
mission_context VARCHAR(64),
pre_mission_change BOOLEAN DEFAULT FALSE,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute(f"""
ALTER TABLE {schema}learnings
ADD CONSTRAINT fk_learnings_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Note: mental_models table recreation is complex and would need separate handling
@@ -1,50 +0,0 @@
"""fix_mental_model_fact_type
Revision ID: q2l3m4n5o6p7
Revises: p1k2l3m4n5o6
Create Date: 2026-01-21 13:30:00.000000
Fix the fact_type check constraint to include 'mental_model'.
This is a fix for p1k2l3m4n5o6 which should have included this change.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "q2l3m4n5o6p7"
down_revision: str | Sequence[str] | None = "p1k2l3m4n5o6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add 'mental_model' to the fact_type check constraint."""
schema = _get_schema_prefix()
# Drop the old constraint and add the new one with mental_model included
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
def downgrade() -> None:
"""Remove 'mental_model' from the fact_type check constraint."""
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
@@ -1,47 +0,0 @@
"""Add reflect_response JSONB column to reflections
Revision ID: r3m4n5o6p7q8
Revises: q2l3m4n5o6p7
Create Date: 2026-01-21
This migration adds a reflect_response JSONB column to store the full
reflect API response payload, including based_on facts and trace data.
Note: Table was renamed from pinned_reflections to reflections in p1k2l3m4n5o6.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "r3m4n5o6p7q8"
down_revision: str | Sequence[str] | None = "q2l3m4n5o6p7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add reflect_response JSONB column to reflections."""
schema = _get_schema_prefix()
# Add reflect_response column to store the full reflect API response
op.execute(f"""
ALTER TABLE {schema}reflections
ADD COLUMN IF NOT EXISTS reflect_response JSONB
""")
def downgrade() -> None:
"""Remove reflect_response column from reflections."""
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}reflections
DROP COLUMN IF EXISTS reflect_response
""")
@@ -1,53 +0,0 @@
"""Add consolidated_at column to memory_units for incremental consolidation tracking.
This allows consolidation to track progress at the memory level rather than
using a bank-level watermark. If consolidation crashes, already-processed
memories won't be reprocessed.
Revision ID: s4n5o6p7q8r9
Revises: r3m4n5o6p7q8
Create Date: 2025-01-22
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "s4n5o6p7q8r9"
down_revision: str | Sequence[str] | None = "r3m4n5o6p7q8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Add consolidated_at column to memory_units
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidated_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Create index for efficient querying of unconsolidated memories
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
ON {schema}memory_units (bank_id, created_at)
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidated_at")
@@ -1,134 +0,0 @@
"""Rename mental_model fact_type to observation and reflections table to mental_models
Revision ID: t5o6p7q8r9s0
Revises: s4n5o6p7q8r9
Create Date: 2026-01-26
This migration implements the terminology rename:
1. mental_model (fact_type in memory_units) -> observation
2. reflections table -> mental_models table
The new terminology:
- Observations: Consolidated knowledge synthesized from facts (was mental_model)
- Mental Models: Stored reflect responses (was reflections)
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "t5o6p7q8r9s0"
down_revision: str | Sequence[str] | None = "s4n5o6p7q8r9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Rename mental_model -> observation and reflections -> mental_models."""
schema = _get_schema_prefix()
# 1. Update fact_type values: mental_model -> observation
op.execute(f"""
UPDATE {schema}memory_units
SET fact_type = 'observation'
WHERE fact_type = 'mental_model'
""")
# 2. Update the CHECK constraint - remove mental_model, keep observation
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
# 3. Rename the index for observations (was for mental_models)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_observations
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'observation'
""")
# 4. Update the unconsolidated index to not filter by fact_type since observations
# are now the consolidated type
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
ON {schema}memory_units (bank_id, created_at)
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
""")
# 5. Rename reflections table to mental_models
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO mental_models")
# 6. Rename indexes for mental_models (was reflections)
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_mental_models_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_mental_models_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_mental_models_tags")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_mental_models_text_search")
# 7. Rename foreign key constraint
op.execute(f"""
ALTER TABLE {schema}mental_models
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT fk_mental_models_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
def downgrade() -> None:
"""Reverse: observation -> mental_model and mental_models -> reflections."""
schema = _get_schema_prefix()
# 1. Rename mental_models table back to reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}mental_models RENAME TO reflections")
# 2. Rename indexes back
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_bank_id RENAME TO idx_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_embedding RENAME TO idx_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_tags RENAME TO idx_reflections_tags")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_text_search RENAME TO idx_reflections_text_search")
# 3. Rename foreign key back
op.execute(f"""
ALTER TABLE {schema}reflections
DROP CONSTRAINT IF EXISTS fk_mental_models_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}reflections
ADD CONSTRAINT fk_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# 4. Update fact_type values: observation -> mental_model
op.execute(f"""
UPDATE {schema}memory_units
SET fact_type = 'mental_model'
WHERE fact_type = 'observation'
""")
# 5. Update the CHECK constraint back
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
# 6. Rename index back
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_observations")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'mental_model'
""")
@@ -1,41 +0,0 @@
"""Change mental_models.id from UUID to TEXT
Revision ID: u6p7q8r9s0t1
Revises: t5o6p7q8r9s0
Create Date: 2026-01-27
This migration changes the mental_models.id column from UUID to TEXT
to support user-defined text identifiers like 'team-communication' instead of UUIDs.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "u6p7q8r9s0t1"
down_revision: str | Sequence[str] | None = "t5o6p7q8r9s0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Change mental_models.id from UUID to TEXT."""
schema = _get_schema_prefix()
# Change the id column type from UUID to TEXT
# Existing UUIDs will be converted to their string representation
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT USING id::TEXT")
def downgrade() -> None:
"""Revert mental_models.id from TEXT to UUID."""
schema = _get_schema_prefix()
# Note: This will fail if any id values are not valid UUIDs
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE UUID USING id::UUID")
@@ -1,50 +0,0 @@
"""Add max_tokens and trigger columns to mental_models
Revision ID: v7q8r9s0t1u2
Revises: u6p7q8r9s0t1
Create Date: 2026-01-27
This migration adds:
- max_tokens column: token limit for content generation during refresh
- trigger column: JSONB for trigger settings (e.g., refresh_after_consolidation)
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "v7q8r9s0t1u2"
down_revision: str | Sequence[str] | None = "u6p7q8r9s0t1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add max_tokens and trigger columns to mental_models."""
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS max_tokens INT NOT NULL DEFAULT 2048
""")
# trigger column stores trigger settings as JSONB
# Default: refresh_after_consolidation = false (not "real time")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS trigger JSONB NOT NULL DEFAULT '{{"refresh_after_consolidation": false}}'::jsonb
""")
def downgrade() -> None:
"""Remove max_tokens and trigger columns from mental_models."""
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS max_tokens")
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS trigger")
File diff suppressed because it is too large Load Diff
+195 -55
View File
@@ -1,4 +1,4 @@
"""Hindsight MCP Server implementation using FastMCP (HTTP transport)."""
"""Hindsight MCP Server implementation using FastMCP."""
import json
import logging
@@ -8,7 +8,8 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
@@ -29,26 +30,15 @@ logger = logging.getLogger(__name__)
# Default bank_id from environment variable
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
# MCP authentication token (optional - if set, Bearer token auth is required)
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
# Context variable to hold the current bank_id
_current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None)
# Context variable to hold the current API key (for tenant auth propagation)
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
return _current_bank_id.get()
def get_current_api_key() -> str | None:
"""Get the current API key from context."""
return _current_api_key.get()
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -62,26 +52,200 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
# Use stateless_http=True for Claude Code compatibility
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
tools=None, # All tools
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
)
@mcp.tool()
async def retain(
content: str,
context: str = "general",
async_processing: bool = True,
bank_id: str | None = None,
) -> str:
"""
Store important information to long-term memory.
register_mcp_tools(mcp, memory, config)
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests
- Important events or milestones
- User history, experiences, or background
- Decisions, opinions, or stated preferences
- Goals, plans, or future intentions
- Relationships or people mentioned
- Work context, projects, or responsibilities
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
contents = [{"content": content, "context": context}]
if async_processing:
# Queue for background processing and return immediately
result = await memory.submit_async_retain(
bank_id=target_bank, contents=contents, request_context=RequestContext()
)
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
else:
# Wait for completion
await memory.retain_batch_async(
bank_id=target_bank,
contents=contents,
request_context=RequestContext(),
)
return f"Memory stored successfully in bank '{target_bank}'"
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
return f"Error: {str(e)}"
@mcp.tool()
async def recall(query: str, max_tokens: int = 4096, bank_id: str | None = None) -> str:
"""
Search memories to provide personalized, context-aware responses.
Use this tool PROACTIVELY to:
- Check user's preferences before making suggestions
- Recall user's history to provide continuity
- Remember user's goals and context
- Personalize responses based on past interactions
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens in the response (default: 4096)
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=RequestContext(),
)
# Use model's JSON serialization
return recall_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return f'{{"error": "{e}", "results": []}}'
@mcp.tool()
async def reflect(query: str, context: str | None = None, budget: str = "low", bank_id: str | None = None) -> str:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=RequestContext(),
)
return reflect_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return f'{{"error": "{e}", "text": ""}}'
@mcp.tool()
async def list_banks() -> str:
"""
List all available memory banks.
Use this tool to discover what memory banks exist in the system.
Each bank is an isolated memory store (like a separate "brain").
Returns:
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=RequestContext())
return json.dumps({"banks": banks}, indent=2)
except Exception as e:
logger.error(f"Error listing banks: {e}", exc_info=True)
return f'{{"error": "{e}", "banks": []}}'
@mcp.tool()
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
Banks are auto-created with default settings if they don't exist.
Args:
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
name: Optional human-friendly name for the bank
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
bank_id,
name=name,
mission=mission,
request_context=RequestContext(),
)
# Fetch updated profile
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Serialize disposition if it's a Pydantic model
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return json.dumps(profile, indent=2)
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
return mcp
class MCPMiddleware:
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
Authentication:
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
Authorization header with Bearer token or direct token matching the configured value.
"""ASGI middleware that extracts bank_id from header or path and sets context.
Bank ID can be provided via:
1. X-Bank-Id header (recommended for Claude Code)
@@ -90,7 +254,7 @@ class MCPMiddleware:
For Claude Code, configure with:
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
--header "X-Bank-Id: my-bank"
"""
def __init__(self, app, memory: MemoryEngine):
@@ -114,22 +278,6 @@ class MCPMiddleware:
await self.mcp_app(scope, receive, send)
return
# Extract auth token from header (for tenant auth propagation)
auth_header = self._get_header(scope, "Authorization")
auth_token: str | None = None
if auth_header:
# Support both "Bearer <token>" and direct token
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
# Authenticate if MCP_AUTH_TOKEN is configured
if MCP_AUTH_TOKEN:
if not auth_token:
await self._send_error(send, 401, "Authorization header required")
return
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
path = scope.get("path", "")
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
@@ -164,10 +312,8 @@ class MCPMiddleware:
bank_id = DEFAULT_BANK_ID
logger.debug(f"Using default bank_id: {bank_id}")
# Set bank_id and api_key context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Set bank_id context
token = _current_bank_id.set(bank_id)
try:
new_scope = scope.copy()
new_scope["path"] = new_path
@@ -186,9 +332,7 @@ class MCPMiddleware:
await self.mcp_app(new_scope, receive, send_wrapper)
finally:
_current_bank_id.reset(bank_id_token)
if api_key_token is not None:
_current_api_key.reset(api_key_token)
_current_bank_id.reset(token)
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
@@ -212,10 +356,6 @@ def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests.
Authentication:
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
If not set, MCP endpoint is open (for local development).
Bank ID can be provided via:
1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
2. URL path: /mcp/{bank_id}/
-3
View File
@@ -83,12 +83,9 @@ def print_startup_info(
embeddings_provider: str,
reranker_provider: str,
mcp_enabled: bool = False,
version: str | None = None,
):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
if version:
print(f" {dim('Version:')} {color(f'v{version}', 0.1)}")
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
print(f" {dim('Database:')} {color(database_url, 0.4)}")
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
+56 -309
View File
@@ -4,12 +4,9 @@ Centralized configuration for Hindsight API.
All environment variables and their defaults are defined here.
"""
import json
import logging
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from dotenv import find_dotenv, load_dotenv
@@ -20,15 +17,11 @@ logger = logging.getLogger(__name__)
# Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL"
ENV_LLM_MAX_CONCURRENT = "HINDSIGHT_API_LLM_MAX_CONCURRENT"
ENV_LLM_MAX_RETRIES = "HINDSIGHT_API_LLM_MAX_RETRIES"
ENV_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_LLM_INITIAL_BACKOFF"
ENV_LLM_MAX_BACKOFF = "HINDSIGHT_API_LLM_MAX_BACKOFF"
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
@@ -37,35 +30,14 @@ ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
ENV_RETAIN_LLM_MODEL = "HINDSIGHT_API_RETAIN_LLM_MODEL"
ENV_RETAIN_LLM_BASE_URL = "HINDSIGHT_API_RETAIN_LLM_BASE_URL"
ENV_RETAIN_LLM_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT"
ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
ENV_REFLECT_LLM_MODEL = "HINDSIGHT_API_REFLECT_LLM_MODEL"
ENV_REFLECT_LLM_BASE_URL = "HINDSIGHT_API_REFLECT_LLM_BASE_URL"
ENV_REFLECT_LLM_MAX_CONCURRENT = "HINDSIGHT_API_REFLECT_LLM_MAX_CONCURRENT"
ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
ENV_CONSOLIDATION_LLM_MODEL = "HINDSIGHT_API_CONSOLIDATION_LLM_MODEL"
ENV_CONSOLIDATION_LLM_BASE_URL = "HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL"
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT"
ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES"
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
@@ -85,7 +57,6 @@ ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
@@ -97,7 +68,6 @@ ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
ENV_HOST = "HINDSIGHT_API_HOST"
ENV_PORT = "HINDSIGHT_API_PORT"
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
@@ -108,22 +78,16 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
# Observation thresholds
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
# Observations settings (consolidated knowledge from facts)
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
@@ -138,57 +102,28 @@ ENV_DB_POOL_MAX_SIZE = "HINDSIGHT_API_DB_POOL_MAX_SIZE"
ENV_DB_COMMAND_TIMEOUT = "HINDSIGHT_API_DB_COMMAND_TIMEOUT"
ENV_DB_ACQUIRE_TIMEOUT = "HINDSIGHT_API_DB_ACQUIRE_TIMEOUT"
# Worker configuration (distributed task processing)
ENV_WORKER_ENABLED = "HINDSIGHT_API_WORKER_ENABLED"
ENV_WORKER_ID = "HINDSIGHT_API_WORKER_ID"
ENV_WORKER_POLL_INTERVAL_MS = "HINDSIGHT_API_WORKER_POLL_INTERVAL_MS"
ENV_WORKER_MAX_RETRIES = "HINDSIGHT_API_WORKER_MAX_RETRIES"
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
ENV_WORKER_MAX_SLOTS = "HINDSIGHT_API_WORKER_MAX_SLOTS"
ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLOTS"
# Background task processing
ENV_TASK_BACKEND = "HINDSIGHT_API_TASK_BACKEND"
ENV_TASK_BACKEND_MEMORY_BATCH_SIZE = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_SIZE"
ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_INTERVAL"
# Reflect agent settings
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_DATABASE_SCHEMA = "public"
DEFAULT_LLM_PROVIDER = "openai"
# Provider-specific default models
PROVIDER_DEFAULT_MODELS = {
"openai": "o3-mini",
"anthropic": "claude-haiku-4-5-20251001",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"ollama": "gemma3:12b",
"lmstudio": "local-model",
"vertexai": "gemini-2.0-flash-001",
"openai-codex": "gpt-5.2-codex",
"claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model",
}
DEFAULT_LLM_MODEL = "o3-mini" # Fallback if provider not in table
DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
# Vertex AI defaults
DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
DEFAULT_LLM_VERTEXAI_REGION = "us-central1"
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
@@ -207,7 +142,6 @@ DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8888
DEFAULT_LOG_LEVEL = "info"
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
DEFAULT_WORKERS = 1
DEFAULT_MCP_ENABLED = True
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
@@ -217,18 +151,17 @@ DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
# Observation thresholds
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_MAX_TOKENS = 1024 # Max tokens for recall when finding related observations
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "verbose"
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
@@ -239,14 +172,10 @@ DEFAULT_DB_POOL_MAX_SIZE = 100
DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # seconds
# Worker configuration (distributed task processing)
DEFAULT_WORKER_ENABLED = True # API runs worker by default (standalone mode)
DEFAULT_WORKER_ID = None # Will use hostname if not specified
DEFAULT_WORKER_POLL_INTERVAL_MS = 500 # Poll database every 500ms
DEFAULT_WORKER_MAX_RETRIES = 3 # Max retries before marking task failed
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
DEFAULT_WORKER_MAX_SLOTS = 10 # Total concurrent tasks per worker
DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks per worker
# Background task processing
DEFAULT_TASK_BACKEND = "memory" # Options: "memory", "noop"
DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE = 10
DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL = 1.0 # seconds
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
@@ -275,36 +204,6 @@ Use this tool PROACTIVELY to:
EMBEDDING_DIMENSION = DEFAULT_EMBEDDING_DIMENSION
class JsonFormatter(logging.Formatter):
"""JSON formatter for structured logging.
Outputs logs in JSON format with a 'severity' field that cloud logging
systems (GCP, AWS CloudWatch, etc.) can parse to correctly categorize log levels.
"""
SEVERITY_MAP = {
logging.DEBUG: "DEBUG",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.ERROR: "ERROR",
logging.CRITICAL: "CRITICAL",
}
def format(self, record: logging.LogRecord) -> str:
log_entry = {
"severity": self.SEVERITY_MAP.get(record.levelno, "DEFAULT"),
"message": record.getMessage(),
"timestamp": datetime.now(timezone.utc).isoformat(),
"logger": record.name,
}
# Add exception info if present
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry)
def _validate_extraction_mode(mode: str) -> str:
"""Validate and normalize extraction mode."""
mode_lower = mode.lower()
@@ -317,18 +216,12 @@ def _validate_extraction_mode(mode: str) -> str:
return mode_lower
def _get_default_model_for_provider(provider: str) -> str:
"""Get the default model for a given provider."""
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
@dataclass
class HindsightConfig:
"""Configuration container for Hindsight API."""
# Database
database_url: str
database_schema: str
# LLM (default, used as fallback for per-operation config)
llm_provider: str
@@ -336,51 +229,22 @@ class HindsightConfig:
llm_model: str
llm_base_url: str | None
llm_max_concurrent: int
llm_max_retries: int
llm_initial_backoff: float
llm_max_backoff: float
llm_timeout: float
# Vertex AI configuration
llm_vertexai_project_id: str | None
llm_vertexai_region: str
llm_vertexai_service_account_key: str | None
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
retain_llm_model: str | None
retain_llm_base_url: str | None
retain_llm_max_concurrent: int | None
retain_llm_max_retries: int | None
retain_llm_initial_backoff: float | None
retain_llm_max_backoff: float | None
retain_llm_timeout: float | None
reflect_llm_provider: str | None
reflect_llm_api_key: str | None
reflect_llm_model: str | None
reflect_llm_base_url: str | None
reflect_llm_max_concurrent: int | None
reflect_llm_max_retries: int | None
reflect_llm_initial_backoff: float | None
reflect_llm_max_backoff: float | None
reflect_llm_timeout: float | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
consolidation_llm_model: str | None
consolidation_llm_base_url: str | None
consolidation_llm_max_concurrent: int | None
consolidation_llm_max_retries: int | None
consolidation_llm_initial_backoff: float | None
consolidation_llm_max_backoff: float | None
consolidation_llm_timeout: float | None
# Embeddings
embeddings_provider: str
embeddings_local_model: str
embeddings_local_force_cpu: bool
embeddings_tei_url: str | None
embeddings_openai_base_url: str | None
embeddings_cohere_base_url: str | None
@@ -388,8 +252,6 @@ class HindsightConfig:
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_local_force_cpu: bool
reranker_local_max_concurrent: int
reranker_tei_url: str | None
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
@@ -400,7 +262,6 @@ class HindsightConfig:
host: str
port: int
log_level: str
log_format: str
mcp_enabled: bool
# Recall
@@ -410,17 +271,16 @@ class HindsightConfig:
recall_connection_budget: int
mental_model_refresh_concurrency: int
# Observation thresholds
observation_min_facts: int
observation_top_entities: int
# Retain settings
retain_max_completion_tokens: int
retain_chunk_size: int
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_custom_instructions: str | None
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
consolidation_batch_size: int
consolidation_max_tokens: int
retain_observations_async: bool
# Optimization flags
skip_llm_verification: bool
@@ -435,14 +295,10 @@ class HindsightConfig:
db_command_timeout: int
db_acquire_timeout: int
# Worker configuration (distributed task processing)
worker_enabled: bool
worker_id: str | None
worker_poll_interval_ms: int
worker_max_retries: int
worker_http_port: int
worker_max_slots: int
worker_consolidation_max_slots: int
# Background task processing
task_backend: str
task_backend_memory_batch_size: int
task_backend_memory_batch_interval: float
# Reflect agent settings
reflect_max_iterations: int
@@ -450,120 +306,34 @@ class HindsightConfig:
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
# Get provider first to determine default model
llm_provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
llm_model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(llm_provider)
return cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
# LLM
llm_provider=llm_provider,
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
llm_api_key=os.getenv(ENV_LLM_API_KEY),
llm_model=llm_model,
llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL),
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
llm_max_concurrent=int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT))),
llm_max_retries=int(os.getenv(ENV_LLM_MAX_RETRIES, str(DEFAULT_LLM_MAX_RETRIES))),
llm_initial_backoff=float(os.getenv(ENV_LLM_INITIAL_BACKOFF, str(DEFAULT_LLM_INITIAL_BACKOFF))),
llm_max_backoff=float(os.getenv(ENV_LLM_MAX_BACKOFF, str(DEFAULT_LLM_MAX_BACKOFF))),
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
retain_llm_model=os.getenv(ENV_RETAIN_LLM_MODEL)
or (
_get_default_model_for_provider(os.getenv(ENV_RETAIN_LLM_PROVIDER))
if os.getenv(ENV_RETAIN_LLM_PROVIDER)
else None
),
retain_llm_model=os.getenv(ENV_RETAIN_LLM_MODEL) or None,
retain_llm_base_url=os.getenv(ENV_RETAIN_LLM_BASE_URL) or None,
retain_llm_max_concurrent=int(os.getenv(ENV_RETAIN_LLM_MAX_CONCURRENT))
if os.getenv(ENV_RETAIN_LLM_MAX_CONCURRENT)
else None,
retain_llm_max_retries=int(os.getenv(ENV_RETAIN_LLM_MAX_RETRIES))
if os.getenv(ENV_RETAIN_LLM_MAX_RETRIES)
else None,
retain_llm_initial_backoff=float(os.getenv(ENV_RETAIN_LLM_INITIAL_BACKOFF))
if os.getenv(ENV_RETAIN_LLM_INITIAL_BACKOFF)
else None,
retain_llm_max_backoff=float(os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF))
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
else None,
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL)
or (
_get_default_model_for_provider(os.getenv(ENV_REFLECT_LLM_PROVIDER))
if os.getenv(ENV_REFLECT_LLM_PROVIDER)
else None
),
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL) or None,
reflect_llm_base_url=os.getenv(ENV_REFLECT_LLM_BASE_URL) or None,
reflect_llm_max_concurrent=int(os.getenv(ENV_REFLECT_LLM_MAX_CONCURRENT))
if os.getenv(ENV_REFLECT_LLM_MAX_CONCURRENT)
else None,
reflect_llm_max_retries=int(os.getenv(ENV_REFLECT_LLM_MAX_RETRIES))
if os.getenv(ENV_REFLECT_LLM_MAX_RETRIES)
else None,
reflect_llm_initial_backoff=float(os.getenv(ENV_REFLECT_LLM_INITIAL_BACKOFF))
if os.getenv(ENV_REFLECT_LLM_INITIAL_BACKOFF)
else None,
reflect_llm_max_backoff=float(os.getenv(ENV_REFLECT_LLM_MAX_BACKOFF))
if os.getenv(ENV_REFLECT_LLM_MAX_BACKOFF)
else None,
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
else None,
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL)
or (
_get_default_model_for_provider(os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER))
if os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER)
else None
),
consolidation_llm_base_url=os.getenv(ENV_CONSOLIDATION_LLM_BASE_URL) or None,
consolidation_llm_max_concurrent=int(os.getenv(ENV_CONSOLIDATION_LLM_MAX_CONCURRENT))
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_CONCURRENT)
else None,
consolidation_llm_max_retries=int(os.getenv(ENV_CONSOLIDATION_LLM_MAX_RETRIES))
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_RETRIES)
else None,
consolidation_llm_initial_backoff=float(os.getenv(ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF))
if os.getenv(ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF)
else None,
consolidation_llm_max_backoff=float(os.getenv(ENV_CONSOLIDATION_LLM_MAX_BACKOFF))
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_BACKOFF)
else None,
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
embeddings_local_force_cpu=os.getenv(
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
).lower()
in ("true", "1"),
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
reranker_local_force_cpu=os.getenv(
ENV_RERANKER_LOCAL_FORCE_CPU, str(DEFAULT_RERANKER_LOCAL_FORCE_CPU)
).lower()
in ("true", "1"),
reranker_local_max_concurrent=int(
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
reranker_tei_max_concurrent=int(
@@ -575,7 +345,6 @@ class HindsightConfig:
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
@@ -590,6 +359,11 @@ class HindsightConfig:
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Observation thresholds
observation_min_facts=int(os.getenv(ENV_OBSERVATION_MIN_FACTS, str(DEFAULT_OBSERVATION_MIN_FACTS))),
observation_top_entities=int(
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
),
# Retain settings
retain_max_completion_tokens=int(
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
@@ -602,15 +376,10 @@ class HindsightConfig:
retain_extraction_mode=_validate_extraction_mode(
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
),
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),
consolidation_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
),
retain_observations_async=os.getenv(
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).lower()
== "true",
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
# Database connection pool
@@ -618,15 +387,13 @@ class HindsightConfig:
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
db_command_timeout=int(os.getenv(ENV_DB_COMMAND_TIMEOUT, str(DEFAULT_DB_COMMAND_TIMEOUT))),
db_acquire_timeout=int(os.getenv(ENV_DB_ACQUIRE_TIMEOUT, str(DEFAULT_DB_ACQUIRE_TIMEOUT))),
# Worker configuration
worker_enabled=os.getenv(ENV_WORKER_ENABLED, str(DEFAULT_WORKER_ENABLED)).lower() == "true",
worker_id=os.getenv(ENV_WORKER_ID) or DEFAULT_WORKER_ID,
worker_poll_interval_ms=int(os.getenv(ENV_WORKER_POLL_INTERVAL_MS, str(DEFAULT_WORKER_POLL_INTERVAL_MS))),
worker_max_retries=int(os.getenv(ENV_WORKER_MAX_RETRIES, str(DEFAULT_WORKER_MAX_RETRIES))),
worker_http_port=int(os.getenv(ENV_WORKER_HTTP_PORT, str(DEFAULT_WORKER_HTTP_PORT))),
worker_max_slots=int(os.getenv(ENV_WORKER_MAX_SLOTS, str(DEFAULT_WORKER_MAX_SLOTS))),
worker_consolidation_max_slots=int(
os.getenv(ENV_WORKER_CONSOLIDATION_MAX_SLOTS, str(DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS))
# Background task processing
task_backend=os.getenv(ENV_TASK_BACKEND, DEFAULT_TASK_BACKEND),
task_backend_memory_batch_size=int(
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_SIZE, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE))
),
task_backend_memory_batch_interval=float(
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL))
),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
@@ -660,32 +427,16 @@ class HindsightConfig:
return log_level_map.get(self.log_level.lower(), logging.INFO)
def configure_logging(self) -> None:
"""Configure Python logging based on the log level and format.
When log_format is "json", outputs structured JSON logs with a severity
field that GCP Cloud Logging can parse for proper log level categorization.
"""
root_logger = logging.getLogger()
root_logger.setLevel(self.get_python_log_level())
# Remove existing handlers
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Create handler writing to stdout (GCP treats stderr as ERROR)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(self.get_python_log_level())
if self.log_format == "json":
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s"))
root_logger.addHandler(handler)
"""Configure Python logging based on the log level."""
logging.basicConfig(
level=self.get_python_log_level(),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
force=True, # Override any existing configuration
)
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {self.database_url} (schema: {self.database_schema})")
logger.info(f"Database: {self.database_url}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
if self.retain_llm_provider or self.retain_llm_model:
retain_provider = self.retain_llm_provider or self.llm_provider
@@ -695,10 +446,6 @@ class HindsightConfig:
reflect_provider = self.reflect_llm_provider or self.llm_provider
reflect_model = self.reflect_llm_model or self.llm_model
logger.info(f"LLM (reflect): provider={reflect_provider}, model={reflect_model}")
if self.consolidation_llm_provider or self.consolidation_llm_model:
consolidation_provider = self.consolidation_llm_provider or self.llm_provider
consolidation_model = self.consolidation_llm_model or self.llm_model
logger.info(f"LLM (consolidation): provider={consolidation_provider}, model={consolidation_model}")
logger.info(f"Embeddings: provider={self.embeddings_provider}")
logger.info(f"Reranker: provider={self.reranker_provider}")
logger.info(f"Graph retriever: {self.graph_retriever}")
+111 -20
View File
@@ -1,10 +1,11 @@
"""
Daemon mode support for Hindsight API.
Provides idle timeout for running as a background daemon.
Provides idle timeout and lockfile management for running as a background daemon.
"""
import asyncio
import fcntl
import logging
import os
import sys
@@ -14,11 +15,10 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Default daemon configuration
DEFAULT_DAEMON_PORT = 8888
DEFAULT_DAEMON_PORT = 8889
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
# Allow override via environment variable for profile-specific logs
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
class IdleTimeoutMiddleware:
@@ -52,10 +52,82 @@ class IdleTimeoutMiddleware:
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
# Give a moment for any in-flight requests
await asyncio.sleep(1)
# Send SIGTERM to ourselves to trigger graceful shutdown
import signal
os._exit(0)
os.kill(os.getpid(), signal.SIGTERM)
class DaemonLock:
"""
File-based lock to prevent multiple daemon instances.
Uses fcntl.flock for atomic locking on Unix systems.
"""
def __init__(self, lockfile: Path = LOCKFILE_PATH):
self.lockfile = lockfile
self._fd = None
def acquire(self) -> bool:
"""
Try to acquire the daemon lock.
Returns True if lock acquired, False if another daemon is running.
"""
self.lockfile.parent.mkdir(parents=True, exist_ok=True)
try:
self._fd = open(self.lockfile, "w")
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# Write PID for debugging
self._fd.write(str(os.getpid()))
self._fd.flush()
return True
except (IOError, OSError):
# Lock is held by another process
if self._fd:
self._fd.close()
self._fd = None
return False
def release(self):
"""Release the daemon lock."""
if self._fd:
try:
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
self._fd.close()
except Exception:
pass
finally:
self._fd = None
# Remove lockfile
try:
self.lockfile.unlink()
except Exception:
pass
def is_locked(self) -> bool:
"""Check if the lock is held by another process."""
if not self.lockfile.exists():
return False
try:
fd = open(self.lockfile, "r")
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# We got the lock, so no one else has it
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
fd.close()
return False
except (IOError, OSError):
return True
def get_pid(self) -> int | None:
"""Get the PID of the daemon holding the lock."""
if not self.lockfile.exists():
return None
try:
with open(self.lockfile, "r") as f:
return int(f.read().strip())
except (ValueError, IOError):
return None
def daemonize():
@@ -64,21 +136,16 @@ def daemonize():
Uses double-fork technique to properly detach from terminal.
"""
# First fork - detach from parent
try:
pid = os.fork()
if pid > 0:
sys.exit(0)
except OSError as e:
sys.stderr.write(f"fork #1 failed: {e}\n")
sys.exit(1)
# First fork
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
# Decouple from parent environment
os.chdir("/")
# Create new session
os.setsid()
os.umask(0)
# Second fork - prevent zombie
# Second fork to prevent zombie processes
pid = os.fork()
if pid > 0:
sys.exit(0)
@@ -111,3 +178,27 @@ def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
return result == 0
except Exception:
return False
def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Stop a running daemon by sending SIGTERM to the process."""
lock = DaemonLock()
pid = lock.get_pid()
if pid is None:
return False
try:
import signal
os.kill(pid, signal.SIGTERM)
# Wait for process to exit
for _ in range(50): # Wait up to 5 seconds
time.sleep(0.1)
try:
os.kill(pid, 0) # Check if process exists
except OSError:
return True # Process exited
return False
except OSError:
return False
@@ -1,5 +0,0 @@
"""Consolidation engine for automatic learning creation from memories."""
from .consolidator import run_consolidation_job
__all__ = ["run_consolidation_job"]
@@ -1,962 +0,0 @@
"""Consolidation engine for automatic observation creation from memories.
The consolidation engine runs as a background job after retain operations complete.
It processes new memories and either:
- Creates new observations from novel facts
- Updates existing observations when new evidence supports/contradicts/refines them
Observations are stored in memory_units with fact_type='observation' and include:
- proof_count: Number of supporting memories
- source_memory_ids: Array of memory UUIDs that contribute to this observation
- history: JSONB tracking changes over time
"""
import json
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from ..memory_engine import fq_table
from ..retain import embedding_utils
from .prompts import (
CONSOLIDATION_SYSTEM_PROMPT,
CONSOLIDATION_USER_PROMPT,
)
if TYPE_CHECKING:
from asyncpg import Connection
from ...api.http import RequestContext
from ..memory_engine import MemoryEngine
logger = logging.getLogger(__name__)
class ConsolidationPerfLog:
"""Performance logging for consolidation operations."""
def __init__(self, bank_id: str):
self.bank_id = bank_id
self.start_time = time.time()
self.lines: list[str] = []
self.timings: dict[str, float] = {}
def log(self, message: str) -> None:
"""Add a log line."""
self.lines.append(message)
def record_timing(self, key: str, duration: float) -> None:
"""Record a timing measurement."""
if key in self.timings:
self.timings[key] += duration
else:
self.timings[key] = duration
def flush(self) -> None:
"""Flush all log lines to the logger."""
total_time = time.time() - self.start_time
header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}"
footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}"
log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer
logger.info(log_output)
async def run_consolidation_job(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Run consolidation job for a bank.
This is called after retain operations to consolidate new memories into mental models.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
Returns:
Dict with consolidation results
"""
from ...config import get_config
config = get_config()
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
# Check if consolidation is enabled
if not config.enable_observations:
logger.debug(f"Consolidation disabled for bank {bank_id}")
return {"status": "disabled", "bank_id": bank_id}
pool = memory_engine._pool
# Get bank profile
async with pool.acquire() as conn:
t0 = time.time()
bank_row = await conn.fetchrow(
f"""
SELECT bank_id, name, mission
FROM {fq_table("banks")}
WHERE bank_id = $1
""",
bank_id,
)
if not bank_row:
logger.warning(f"Bank {bank_id} not found for consolidation")
return {"status": "bank_not_found", "bank_id": bank_id}
mission = bank_row["mission"] or "General memory consolidation"
perf.record_timing("fetch_bank", time.time() - t0)
# Count total unconsolidated memories for progress logging
total_count = await conn.fetchval(
f"""
SELECT COUNT(*)
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
if total_count == 0:
logger.debug(f"No new memories to consolidate for bank {bank_id}")
return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
perf.log(f"[1] Found {total_count} pending memories to consolidate")
# Process each memory with individual commits for crash recovery
stats = {
"memories_processed": 0,
"observations_created": 0,
"observations_updated": 0,
"observations_merged": 0,
"actions_executed": 0,
"skipped": 0,
}
batch_num = 0
last_progress_timings = {} # Track timings at last progress log
while True:
batch_num += 1
batch_start = time.time()
# Snapshot timings at batch start for per-batch calculation
batch_start_timings = perf.timings.copy()
# Fetch next batch of unconsolidated memories
async with pool.acquire() as conn:
t0 = time.time()
memories = await conn.fetch(
f"""
SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
""",
bank_id,
max_memories_per_batch,
)
perf.record_timing("fetch_memories", time.time() - t0)
if not memories:
break # No more unconsolidated memories
for memory in memories:
mem_start = time.time()
# Process the memory (uses its own connection internally)
async with pool.acquire() as conn:
result = await _process_memory(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory=dict(memory),
mission=mission,
request_context=request_context,
perf=perf,
)
# Mark memory as consolidated (committed immediately)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET consolidated_at = NOW()
WHERE id = $1
""",
memory["id"],
)
mem_time = time.time() - mem_start
perf.record_timing("process_memory_total", mem_time)
stats["memories_processed"] += 1
action = result.get("action")
if action == "created":
stats["observations_created"] += 1
stats["actions_executed"] += 1
elif action == "updated":
stats["observations_updated"] += 1
stats["actions_executed"] += 1
elif action == "merged":
stats["observations_merged"] += 1
stats["actions_executed"] += 1
elif action == "multiple":
stats["observations_created"] += result.get("created", 0)
stats["observations_updated"] += result.get("updated", 0)
stats["observations_merged"] += result.get("merged", 0)
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
# Log progress periodically with timing breakdown
if stats["memories_processed"] % 10 == 0:
# Calculate timing deltas since last progress log
timing_parts = []
for key in ["recall", "llm", "embedding", "db_write"]:
if key in perf.timings:
delta = perf.timings[key] - last_progress_timings.get(key, 0)
timing_parts.append(f"{key}={delta:.2f}s")
timing_str = f" | {', '.join(timing_parts)}" if timing_parts else ""
logger.info(
f"[CONSOLIDATION] bank={bank_id} progress: "
f"{stats['memories_processed']}/{total_count} memories processed{timing_str}"
)
# Update last progress snapshot
last_progress_timings = perf.timings.copy()
batch_time = time.time() - batch_start
perf.log(
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
f"(avg {batch_time / len(memories):.3f}s/memory)"
)
# Log timing breakdown after each batch (delta from batch start)
timing_parts = []
for key in ["recall", "llm", "embedding", "db_write"]:
if key in perf.timings:
delta = perf.timings[key] - batch_start_timings.get(key, 0)
timing_parts.append(f"{key}={delta:.3f}s")
if timing_parts:
avg_per_memory = batch_time / len(memories) if memories else 0
logger.info(
f"[CONSOLIDATION] bank={bank_id} batch {batch_num}/{len(memories)} memories: "
f"{', '.join(timing_parts)} | avg={avg_per_memory:.3f}s/memory"
)
# Build summary
perf.log(
f"[3] Results: {stats['memories_processed']} memories -> "
f"{stats['actions_executed']} actions "
f"({stats['observations_created']} created, "
f"{stats['observations_updated']} updated, "
f"{stats['observations_merged']} merged, "
f"{stats['skipped']} skipped)"
)
# Add timing breakdown
timing_parts = []
if "recall" in perf.timings:
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
if "llm" in perf.timings:
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
if "embedding" in perf.timings:
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
if "db_write" in perf.timings:
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
if timing_parts:
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes for models with refresh_after_consolidation=true
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
bank_id=bank_id,
request_context=request_context,
perf=perf,
)
stats["mental_models_refreshed"] = mental_models_refreshed
perf.flush()
return {"status": "completed", "bank_id": bank_id, **stats}
async def _trigger_mental_model_refreshes(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
perf: ConsolidationPerfLog | None = None,
) -> int:
"""
Trigger refreshes for mental models with refresh_after_consolidation=true.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
perf: Performance logging
Returns:
Number of mental models scheduled for refresh
"""
pool = memory_engine._pool
# Find mental models with refresh_after_consolidation=true
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, name
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
""",
bank_id,
)
if not rows:
return 0
if perf:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
# Submit refresh tasks for each mental model
refreshed_count = 0
for row in rows:
mental_model_id = row["id"]
try:
await memory_engine.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
refreshed_count += 1
logger.info(
f"[CONSOLIDATION] Triggered refresh for mental model {mental_model_id} "
f"(name: {row['name']}) in bank {bank_id}"
)
except Exception as e:
logger.warning(f"[CONSOLIDATION] Failed to trigger refresh for mental model {mental_model_id}: {e}")
return refreshed_count
async def _process_memory(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory: dict[str, Any],
mission: str,
request_context: "RequestContext",
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Process a single memory for consolidation using a SINGLE LLM call.
This function:
1. Finds related observations (can be empty)
2. Uses ONE LLM call to extract durable knowledge AND decide on actions
3. Executes array of actions (can be multiple creates/updates)
The LLM handles all cases:
- No related observations: returns create action(s) with extracted durable knowledge
- Related observations exist: returns update/create actions based on tag routing
- Purely ephemeral fact: returns empty array (skip)
Returns:
Dict with action summary: created/updated/merged counts
"""
fact_text = memory["text"]
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related observations using the full recall system (NO tag filtering)
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
)
if perf:
perf.record_timing("recall", time.time() - t0)
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
# Summarize results
created = sum(1 for r in results if r.get("action") == "created")
updated = sum(1 for r in results if r.get("action") == "updated")
merged = sum(1 for r in results if r.get("action") == "merged")
if len(results) == 1:
return results[0]
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
async def _execute_update_action(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
observations: list[dict[str, Any]],
source_fact_tags: list[str] | None = None,
source_occurred_start: datetime | None = None,
source_occurred_end: datetime | None = None,
source_mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute an update action on an existing observation.
Updates the observation text, adds to history, increments proof_count,
and updates temporal fields:
- occurred_start: uses LEAST to keep the earliest start time
- occurred_end: uses GREATEST to keep the most recent end time
- mentioned_at: uses GREATEST to keep the most recent mention time
SECURITY: Merges source fact's tags into the observation's existing tags.
This ensures all contributors can see the observation they contributed to.
For example, if Lisa's observation (tags=['user_lisa']) is updated with
Mike's fact (tags=['user_mike']), the observation will have both tags.
"""
learning_id = action.get("learning_id")
new_text = action.get("text")
reason = action.get("reason", "Updated with new fact")
if not learning_id or not new_text:
return {"action": "skipped", "reason": "missing_learning_id_or_text"}
# Find the observation
model = next((m for m in observations if str(m["id"]) == learning_id), None)
if not model:
return {"action": "skipped", "reason": "learning_not_found"}
# Build history entry
history = list(model.get("history", []))
history.append(
{
"previous_text": model["text"],
"changed_at": datetime.now(timezone.utc).isoformat(),
"reason": reason,
"source_memory_id": str(memory_id),
}
)
# Update source_memory_ids
source_ids = list(model.get("source_memory_ids", []))
source_ids.append(memory_id)
# SECURITY: Merge source fact's tags into existing observation tags
# This ensures all contributors can see the observation they contributed to
existing_tags = set(model.get("tags", []) or [])
source_tags = set(source_fact_tags or [])
merged_tags = list(existing_tags | source_tags) # Union of both tag sets
if source_tags and source_tags != existing_tags:
logger.debug(
f"Security: Merging tags for observation {learning_id}: "
f"existing={list(existing_tags)}, source={list(source_tags)}, merged={merged_tags}"
)
# Generate new embedding for updated text
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
embedding_str = str(embeddings[0]) if embeddings else None
if perf:
perf.record_timing("embedding", time.time() - t0)
# Update the observation
# - occurred_start: LEAST keeps the earliest start time across all source facts
# - occurred_end: GREATEST keeps the most recent end time across all source facts
# - mentioned_at: GREATEST keeps the most recent mention time
# - tags: merged from existing + source fact (for visibility)
t0 = time.time()
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
history = $3,
source_memory_ids = $4,
proof_count = $5,
tags = $10,
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
WHERE id = $6
""",
new_text,
embedding_str,
json.dumps(history),
source_ids,
len(source_ids),
uuid.UUID(learning_id),
source_occurred_start,
source_occurred_end,
source_mentioned_at,
merged_tags,
)
# Create links from memory to observation
await _create_memory_links(conn, memory_id, uuid.UUID(learning_id))
if perf:
perf.record_timing("db_write", time.time() - t0)
logger.debug(f"Updated observation {learning_id} with memory {memory_id}")
return {"action": "updated", "observation_id": learning_id}
async def _execute_create_action(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
source_fact_tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
occurred_end: datetime | None = None,
mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute a create action for a new observation.
Creates a new observation with the specified text.
The text comes directly from the classify LLM - no second LLM call needed.
Tags are determined algorithmically (not by LLM):
- Observations always inherit their source fact's tags
- This ensures visibility scope is maintained (security)
"""
text = action.get("text")
# Tags are determined algorithmically - always use source fact's tags
# This ensures private memories create private observations
tags = source_fact_tags or []
if not text:
return {"action": "skipped", "reason": "missing_text"}
# Use text directly from classify - skip the redundant LLM call
result = await _create_observation_directly(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_id=memory_id,
observation_text=text, # Text already processed by classify LLM
tags=tags,
event_date=event_date,
occurred_start=occurred_start,
occurred_end=occurred_end,
mentioned_at=mentioned_at,
perf=perf,
)
logger.debug(f"Created observation {result.get('observation_id')} from memory {memory_id} (tags: {tags})")
return result
async def _create_memory_links(
conn: "Connection",
memory_id: uuid.UUID,
observation_id: uuid.UUID,
) -> None:
"""
Placeholder for observation link creation.
Observations do NOT get any memory_links copied from their source facts.
Instead, retrieval uses source_memory_ids to traverse:
- Entity connections: observation → source_memory_ids → unit_entities
- Semantic similarity: observations have their own embeddings
- Temporal proximity: observations have their own temporal fields
This avoids data duplication and ensures observations are always
connected via their source facts' relationships.
The memory_id and observation_id parameters are kept for interface
compatibility but no links are created.
"""
# No links are created - observations rely on source_memory_ids for traversal
pass
async def _find_related_observations(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
Find observations related to the given query using optimized recall.
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
potentially related observations regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
Uses max_tokens to naturally limit observations (no artificial count limit).
Includes source memories with dates for LLM context.
Returns:
List of related observations with their tags, source memories, and dates
"""
# Use recall to find related observations with token budget
# max_tokens naturally limits how many observations are returned
from ...config import get_config
config = get_config()
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL observations
)
# If no observations returned, return empty list
if not recall_result.results:
return []
# Batch fetch all observations in a single query (no artificial limit)
observation_ids = [uuid.UUID(obs.id) for obs in recall_result.results]
rows = await conn.fetch(
f"""
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at,
occurred_start, occurred_end, mentioned_at
FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2 AND fact_type = 'observation'
""",
observation_ids,
bank_id,
)
# Build results list preserving recall order
id_to_row = {row["id"]: row for row in rows}
results = []
for obs in recall_result.results:
obs_id = uuid.UUID(obs.id)
if obs_id not in id_to_row:
continue
row = id_to_row[obs_id]
history = row["history"]
if isinstance(history, str):
history = json.loads(history)
elif history is None:
history = []
# Fetch source memories to include their text and dates
source_memory_ids = row["source_memory_ids"] or []
source_memories = []
if source_memory_ids:
source_rows = await conn.fetch(
f"""
SELECT text, occurred_start, occurred_end, mentioned_at, event_date
FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2
ORDER BY created_at ASC
LIMIT 5
""",
source_memory_ids[:5], # Limit to first 5 source memories for token efficiency
bank_id,
)
for src_row in source_rows:
source_memories.append(
{
"text": src_row["text"],
"occurred_start": src_row["occurred_start"],
"occurred_end": src_row["occurred_end"],
"mentioned_at": src_row["mentioned_at"],
"event_date": src_row["event_date"],
}
)
results.append(
{
"id": row["id"],
"text": row["text"],
"proof_count": row["proof_count"] or 1,
"tags": row["tags"] or [],
"source_memories": source_memories,
"occurred_start": row["occurred_start"],
"occurred_end": row["occurred_end"],
"mentioned_at": row["mentioned_at"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
return results
async def _consolidate_with_llm(
memory_engine: "MemoryEngine",
fact_text: str,
observations: list[dict[str, Any]],
mission: str,
) -> list[dict[str, Any]]:
"""
Single LLM call to extract durable knowledge and decide on consolidation actions.
This handles ALL cases:
- No related observations: extracts durable knowledge, returns create action
- Related observations exist: compares and returns update/create actions
- Purely ephemeral fact: returns empty array
Note: Tags are NOT handled by the LLM. They are determined algorithmically:
- CREATE: observation inherits source fact's tags
- UPDATE: observation merges source fact's tags with existing tags
Returns:
List of actions, each being:
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
- {"action": "create", "text": "...", "reason": "..."}
- [] if fact is purely ephemeral (no durable knowledge)
"""
# Format observations as JSON with source memories and dates
if observations:
obs_list = []
for obs in observations:
obs_data = {
"id": str(obs["id"]),
"text": obs["text"],
"proof_count": obs["proof_count"],
"tags": obs["tags"],
"created_at": obs["created_at"].isoformat() if obs.get("created_at") else None,
"updated_at": obs["updated_at"].isoformat() if obs.get("updated_at") else None,
}
# Include temporal info if available
if obs.get("occurred_start"):
obs_data["occurred_start"] = obs["occurred_start"].isoformat()
if obs.get("occurred_end"):
obs_data["occurred_end"] = obs["occurred_end"].isoformat()
if obs.get("mentioned_at"):
obs_data["mentioned_at"] = obs["mentioned_at"].isoformat()
# Include source memories (up to 3 for brevity)
if obs.get("source_memories"):
obs_data["source_memories"] = [
{
"text": sm["text"],
"event_date": sm["event_date"].isoformat() if sm.get("event_date") else None,
"occurred_start": sm["occurred_start"].isoformat() if sm.get("occurred_start") else None,
}
for sm in obs["source_memories"][:3] # Limit to 3 for token efficiency
]
obs_list.append(obs_data)
observations_text = json.dumps(obs_list, indent=2)
else:
observations_text = "[]"
# Only include mission section if mission is set and not the default
mission_section = ""
if mission and mission != "General memory consolidation":
mission_section = f"""
MISSION CONTEXT: {mission}
Focus on DURABLE knowledge that serves this mission, not ephemeral state.
"""
user_prompt = CONSOLIDATION_USER_PROMPT.format(
mission_section=mission_section,
fact_text=fact_text,
observations_text=observations_text,
)
messages = [
{"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
try:
result = await memory_engine._consolidation_llm_config.call(
messages=messages,
skip_validation=True, # Raw JSON response
scope="consolidation",
)
# Parse JSON response - should be an array
if isinstance(result, str):
# Strip markdown code fences (some models wrap JSON in ```json ... ```)
clean = result.strip()
if clean.startswith("```"):
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
if clean.endswith("```"):
clean = clean[:-3]
clean = clean.strip()
result = json.loads(clean)
# Ensure result is a list
if isinstance(result, list):
return result
# Handle legacy single-action format for backward compatibility
if isinstance(result, dict):
if result.get("related_ids") and result.get("consolidated_text"):
# Convert old format to new format
return [
{
"action": "update",
"learning_id": result["related_ids"][0],
"text": result["consolidated_text"],
"reason": result.get("reason", ""),
}
]
return []
return []
except Exception as e:
logger.warning(f"Error in consolidation LLM call: {e}")
return []
async def _create_observation_directly(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
source_memory_id: uuid.UUID,
observation_text: str,
tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
occurred_end: datetime | None = None,
mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Create an observation directly with pre-processed text (no LLM call).
Used when the classify LLM has already provided the learning text.
This avoids the redundant second LLM call.
"""
# Generate embedding for the observation (convert to string for pgvector)
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [observation_text])
embedding_str = str(embeddings[0]) if embeddings else None
if perf:
perf.record_timing("embedding", time.time() - t0)
# Create the observation as a memory_unit
now = datetime.now(timezone.utc)
obs_event_date = event_date or now
obs_occurred_start = occurred_start or now
obs_occurred_end = occurred_end or now
obs_mentioned_at = mentioned_at or now
obs_tags = tags or []
t0 = time.time()
observation_id = uuid.uuid4()
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
tags, event_date, occurred_start, occurred_end, mentioned_at
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
RETURNING id
""",
observation_id,
bank_id,
observation_text,
embedding_str,
[source_memory_id],
obs_tags,
obs_event_date,
obs_occurred_start,
obs_occurred_end,
obs_mentioned_at,
)
# Create links between memory and observation (includes entity links, memory_links)
await _create_memory_links(conn, source_memory_id, observation_id)
if perf:
perf.record_timing("db_write", time.time() - t0)
logger.debug(f"Created observation {observation_id} from memory {source_memory_id} (tags: {obs_tags})")
return {"action": "created", "observation_id": str(row["id"]), "tags": obs_tags}
@@ -1,77 +0,0 @@
"""Prompts for the consolidation engine."""
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
Examples of extracting durable knowledge:
- "User moved to Room 203" -> "Room 203 exists" (location exists, not where user is now)
- "User visited Acme Corp at Room 105" -> "Acme Corp is located in Room 105"
- "User took the elevator to floor 3" -> "Floor 3 is accessible by elevator"
- "User met Sarah at the lobby" -> "Sarah can be found at the lobby"
DO NOT track current user position/state as knowledge - that changes constantly.
DO track permanent facts learned from the user's actions.
## PRESERVE SPECIFIC DETAILS
Keep names, locations, numbers, and other specifics. Do NOT:
- Abstract into general principles
- Generate business insights
- Make knowledge generic
GOOD examples:
- Fact: "John likes pizza" -> "John likes pizza"
- Fact: "Alice works at Google" -> "Alice works at Google"
BAD examples:
- "John likes pizza" -> "Understanding dietary preferences helps..." (TOO ABSTRACT)
- "User is at Room 203" -> "User is currently at Room 203" (EPHEMERAL STATE)
## MERGE RULES (when comparing to existing observations):
1. REDUNDANT: Same information worded differently → update existing
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
3. UPDATE: New state replacing old state → update with history
## CRITICAL RULES:
- NEVER merge facts about DIFFERENT people
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
- When merging contradictions, capture the CHANGE (before → after)
- Keep observations focused on ONE specific topic per person
- The "text" field MUST contain durable knowledge, not ephemeral state
- Do NOT include "tags" in output - tags are handled automatically"""
CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge.
{mission_section}
NEW FACT: {fact_text}
EXISTING OBSERVATIONS (JSON array with source memories and dates):
{observations_text}
Each observation includes:
- id: unique identifier for updating
- text: the observation content
- proof_count: number of supporting memories
- tags: visibility scope (handled automatically)
- created_at/updated_at: when observation was created/modified
- occurred_start/occurred_end: temporal range of source facts
- source_memories: array of supporting facts with their text and dates
Instructions:
1. Extract DURABLE KNOWLEDGE from the new fact (not ephemeral state)
2. Review source_memories in existing observations to understand evidence
3. Check dates to detect contradictions or updates
4. Compare with observations:
- Same topic → UPDATE with learning_id
- New topic → CREATE new observation
- Purely ephemeral → return []
Output JSON array of actions:
[
{{"action": "update", "learning_id": "uuid-from-observations", "text": "updated knowledge", "reason": "..."}},
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
]
Return [] if fact contains no durable knowledge."""
@@ -20,7 +20,6 @@ from ..config import (
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_PROVIDER,
@@ -34,7 +33,6 @@ from ..config import (
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_LITELLM_MODEL,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_PROVIDER,
@@ -101,7 +99,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
_executor: ThreadPoolExecutor | None = None
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
def __init__(self, model_name: str | None = None, max_concurrent: int = 4, force_cpu: bool = False):
def __init__(self, model_name: str | None = None, max_concurrent: int = 4):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -110,11 +108,8 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
max_concurrent: Maximum concurrent reranking calls (default: 2).
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -135,38 +130,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
"Install it with: pip install sentence-transformers"
)
# Note: We use CPU even when GPU/MPS is available because:
# 1. The reranker model (MiniLM) is tiny (~22M params)
# 2. Batch sizes are small (~100-200 pairs)
# 3. Data transfer overhead to GPU outweighs compute benefit
# 4. CPU inference is actually faster for this workload
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
# after loading, which conflicts with accelerate's device_map handling.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
self._model = CrossEncoder(self.model_name)
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
@@ -178,11 +148,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
else:
logger.info("Reranker: local provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous prediction wrapper for thread pool execution."""
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
@@ -200,11 +165,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Use dedicated executor - limited workers naturally limits concurrency
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
scores = await loop.run_in_executor(
LocalSTCrossEncoder._executor,
self._predict_sync,
pairs,
lambda: self._model.predict(pairs, show_progress_bar=False),
)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
@@ -614,7 +579,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return
try:
from flashrank import Ranker
from flashrank import Ranker # type: ignore[import-untyped]
except ImportError:
raise ImportError("flashrank is required for FlashRankCrossEncoder. Install it with: pip install flashrank")
@@ -641,7 +606,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict - processes each query group."""
from flashrank import RerankRequest
from flashrank import RerankRequest # type: ignore[import-untyped]
if not pairs:
return []
@@ -803,33 +768,29 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
Create a CrossEncoderModel instance based on environment variables.
Reads configuration via get_config() to ensure consistency across the codebase.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured CrossEncoderModel instance
"""
from ..config import get_config
config = get_config()
provider = config.reranker_provider.lower()
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
if provider == "tei":
url = config.reranker_tei_url
url = os.environ.get(ENV_RERANKER_TEI_URL)
if not url:
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
return RemoteTEICrossEncoder(
base_url=url,
batch_size=config.reranker_tei_batch_size,
max_concurrent=config.reranker_tei_max_concurrent,
)
batch_size = int(os.environ.get(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE)))
max_concurrent = int(os.environ.get(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT)))
return RemoteTEICrossEncoder(base_url=url, batch_size=batch_size, max_concurrent=max_concurrent)
elif provider == "local":
return LocalSTCrossEncoder(
model_name=config.reranker_local_model,
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
max_concurrent = int(
os.environ.get(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
)
return LocalSTCrossEncoder(model_name=model_name, max_concurrent=max_concurrent)
elif provider == "cohere":
api_key = os.environ.get(ENV_COHERE_API_KEY)
if not api_key:
@@ -1,5 +0,0 @@
"""Directives module for hard rules injected into prompts."""
from .models import Directive
__all__ = ["Directive"]
@@ -1,37 +0,0 @@
"""Pydantic models for directives."""
from datetime import datetime, timezone
from uuid import UUID
from pydantic import BaseModel, Field
class Directive(BaseModel):
"""A directive is a hard rule injected into prompts.
Directives are user-defined rules that guide agent behavior. Unlike mental models
which are automatically consolidated from memories, directives are explicit
instructions that are always included in relevant prompts.
Examples:
- "Always respond in formal English"
- "Never share personal data with third parties"
- "Prefer conservative investment recommendations"
"""
id: UUID = Field(description="Unique identifier")
bank_id: str = Field(description="Bank this directive belongs to")
name: str = Field(description="Human-readable name")
content: str = Field(description="The directive text to inject into prompts")
priority: int = Field(default=0, description="Higher priority directives are injected first")
is_active: bool = Field(default=True, description="Whether this directive is currently active")
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created"
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated"
)
class Config:
from_attributes = True
@@ -18,7 +18,6 @@ import httpx
from ..config import (
DEFAULT_EMBEDDINGS_COHERE_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
@@ -27,7 +26,6 @@ from ..config import (
ENV_EMBEDDINGS_COHERE_BASE_URL,
ENV_EMBEDDINGS_COHERE_MODEL,
ENV_EMBEDDINGS_LITELLM_MODEL,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
@@ -94,18 +92,15 @@ class LocalSTEmbeddings(Embeddings):
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
def __init__(self, model_name: str | None = None):
"""
Initialize local SentenceTransformers embeddings.
Args:
model_name: Name of the SentenceTransformer model to use.
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.force_cpu = force_cpu
self._model = None
self._dimension: int | None = None
@@ -133,34 +128,11 @@ class LocalSTEmbeddings(Embeddings):
)
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
self._dimension = self._model.get_sentence_embedding_dimension()
@@ -178,7 +150,6 @@ class LocalSTEmbeddings(Embeddings):
"""
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
@@ -545,7 +516,7 @@ class CohereEmbeddings(Embeddings):
model=self.model,
input_type=self.input_type,
)
if response.embeddings and isinstance(response.embeddings, list):
if response.embeddings:
self._dimension = len(response.embeddings[0])
logger.info(f"Embeddings: Cohere provider initialized (model: {self.model}, dim: {self._dimension})")
@@ -702,28 +673,24 @@ class LiteLLMEmbeddings(Embeddings):
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on configuration.
Create an Embeddings instance based on environment variables.
Reads configuration via get_config() to ensure consistency across the codebase.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured Embeddings instance
"""
from ..config import get_config
config = get_config()
provider = config.embeddings_provider.lower()
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
if provider == "tei":
url = config.embeddings_tei_url
url = os.environ.get(ENV_EMBEDDINGS_TEI_URL)
if not url:
raise ValueError(f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'")
return RemoteTEIEmbeddings(base_url=url)
elif provider == "local":
return LocalSTEmbeddings(
model_name=config.embeddings_local_model,
force_cpu=config.embeddings_local_force_cpu,
)
model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL)
model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL
return LocalSTEmbeddings(model_name=model_name)
elif provider == "openai":
# Use dedicated embeddings API key, or fall back to LLM API key
api_key = os.environ.get(ENV_EMBEDDINGS_OPENAI_API_KEY) or os.environ.get(ENV_LLM_API_KEY)
@@ -442,6 +442,49 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def get_entity_observations(
self,
bank_id: str,
entity_id: str,
*,
limit: int = 10,
request_context: "RequestContext",
) -> list[Any]:
"""
Get observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
limit: Maximum observations.
request_context: Request context for authentication.
Returns:
List of EntityObservation objects.
"""
...
@abstractmethod
async def regenerate_entity_observations(
self,
bank_id: str,
entity_id: str,
entity_name: str,
*,
request_context: "RequestContext",
) -> None:
"""
Regenerate observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
entity_name: The entity's canonical name.
request_context: Request context for authentication.
"""
...
# =========================================================================
# Statistics & Operations
# =========================================================================
@@ -1,146 +0,0 @@
"""
Abstract interface for LLM providers.
This module defines the interface that all LLM providers must implement,
enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, etc.)
"""
from abc import ABC, abstractmethod
from typing import Any
from .response_models import LLMToolCallResult, TokenUsage
class LLMInterface(ABC):
"""
Abstract interface for LLM providers.
All LLM provider implementations must inherit from this class and implement
the required methods.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""
Initialize LLM provider.
Args:
provider: Provider name (e.g., "openai", "codex", "anthropic", "gemini").
api_key: API key or authentication token.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
**kwargs: Additional provider-specific parameters.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
@abstractmethod
async def verify_connection(self) -> None:
"""
Verify that the LLM provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
pass
@abstractmethod
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make an LLM API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with token counts.
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
pass
@abstractmethod
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
pass
@abstractmethod
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
pass
class OutputTooLongError(Exception):
"""
Bridge exception raised when LLM output exceeds token limits.
This wraps provider-specific errors (e.g., OpenAI's LengthFinishReasonError)
to allow callers to handle output length issues without depending on
provider-specific implementations.
"""
pass
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,16 @@
"""
Mental models module for Hindsight.
Mental models contain directives - hard rules that are injected into reflect prompts.
Directives are user-defined and their observations are user-provided (not LLM-generated).
Mental models are synthesized summaries that represent understanding. They come
in different subtypes based on how they were created:
Other types of consolidated knowledge are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team")
These are created upfront based on what any agent with this role would need.
- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.)
These surface organically as facts are retained.
- Pinned: User-defined models that persist across refreshes.
"""
from .models import MentalModel, MentalModelSubtype
@@ -0,0 +1,311 @@
"""
Emergent mental model detection and promotion.
Emergent models are discovered from data patterns:
- Named entity extraction (people, projects, systems)
- Temporal clustering (events with multiple references)
- Causal patterns ("Because X, we do Y")
- Behavioral anchors ("After X, we started Y")
- Reference frequency (anything mentioned repeatedly)
When a pattern is detected, it goes through a mission filter to check relevance,
and if relevant, is promoted to a mental model.
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import EmergentCandidate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class MissionFilterCandidate(BaseModel):
"""Result of mission filtering for a single candidate."""
name: str
promote: bool = Field(description="True if this is a specific named entity worth tracking")
reason: str = Field(description="Brief explanation for the decision")
class MissionFilterResponse(BaseModel):
"""Response from LLM for mission filtering."""
candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
"""Build the prompt for filtering candidates by mission relevance."""
candidate_list = "\n".join(
[f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
)
return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
MISSION: {mission}
DETECTED ENTITIES:
{candidate_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
- "Maria" → YES (promote=true)
- "park" → NO (promote=false)
When in doubt, set promote=false."""
def get_mission_filter_system_message() -> str:
"""System message for mission filtering."""
return """You filter entities for promotion. Output JSON with 'candidates' array.
Rules:
- promote=true ONLY for specific names (people, organizations, named places/projects)
- promote=false for common words, generic categories, abstract concepts
Examples:
- "John" → promote=true (person name)
- "kids" → promote=false (generic category)
- "community" → promote=false (abstract concept)
- "Google" → promote=true (organization name)
- "motivation" → promote=false (abstract concept)
When in doubt, promote=false. Most entities should be rejected."""
async def filter_candidates_by_mission(
llm_config: "LLMConfig",
mission: str,
candidates: list[EmergentCandidate],
) -> list[EmergentCandidate]:
"""
Filter emergent candidates to keep only specific, named entities.
Args:
llm_config: LLM configuration
mission: The bank's mission (used for context)
candidates: List of detected candidates
Returns:
Filtered list of candidates that are specific named entities
"""
if not candidates:
return []
if not mission:
# No mission = no filtering, keep all candidates
logger.debug("[EMERGENT] No mission set, skipping filter")
return candidates
prompt = build_mission_filter_prompt(mission, candidates)
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_mission_filter",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Filter candidates
filtered = []
for candidate in candidates:
if candidate.name in promote_map:
if promote_map[candidate.name]:
filtered.append(candidate)
logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
else:
logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
else:
# Candidate not in response - reject by default
logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
return filtered
except Exception as e:
logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
return []
async def evaluate_emergent_models(
llm_config: "LLMConfig",
models: list[dict],
) -> list[str]:
"""
Evaluate existing emergent models to check if they should be kept.
This re-evaluates emergent models using the same filtering criteria
as new candidates. Models that are generic/abstract will be removed.
Args:
llm_config: LLM configuration
models: List of existing emergent model dicts with 'name', 'id'
Returns:
List of model IDs that should be REMOVED (no longer valid)
"""
if not models:
return []
# Convert existing models to candidates for evaluation
candidates = [
EmergentCandidate(
name=m["name"],
detection_method="existing_emergent_model",
mention_count=0,
)
for m in models
]
# Build a simple prompt for re-evaluation
names_list = "\n".join([f"- {m['name']}" for m in models])
prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
EXISTING MODELS:
{names_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
When in doubt, set promote=false."""
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_emergent_evaluation",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Find models to remove
models_to_remove = []
for model in models:
name = model["name"]
if name in promote_map:
if not promote_map[name]:
models_to_remove.append(model["id"])
else:
logger.debug(f"[EMERGENT] Keeping '{name}'")
else:
# Model not in response - remove to be safe
logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
models_to_remove.append(model["id"])
logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
return models_to_remove
except Exception as e:
logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
return []
async def detect_entity_candidates(
pool,
bank_id: str,
min_mentions: int = 5,
top_percent: int = 20,
) -> list[EmergentCandidate]:
"""
Detect entities that are candidates for promotion to mental models.
Args:
pool: Database connection pool
bank_id: Bank identifier
min_mentions: Minimum mention count to consider
top_percent: Only consider top X% by mention count
Returns:
List of entity candidates
"""
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
candidates = []
async with acquire_with_retry(pool) as conn:
# Get entities that meet criteria and don't already have mental models
rows = await conn.fetch(
f"""
WITH ranked AS (
SELECT
e.id,
e.canonical_name,
e.mention_count,
PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
FROM {fq_table("entities")} e
LEFT JOIN {fq_table("mental_models")} mm
ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
WHERE e.bank_id = $1
AND e.mention_count >= $2
AND mm.id IS NULL -- Not already a mental model
)
SELECT id, canonical_name, mention_count
FROM ranked
WHERE rank_pct <= $3
ORDER BY mention_count DESC
LIMIT 50
""",
bank_id,
min_mentions,
top_percent / 100.0,
)
for row in rows:
candidates.append(
EmergentCandidate(
name=row["canonical_name"],
detection_method="named_entity_extraction",
mention_count=row["mention_count"],
entity_id=str(row["id"]),
relevance_score=0.0,
)
)
logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
return candidates
@@ -9,15 +9,12 @@ from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model.
"""Subtype of mental model - how it was created."""
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
STRUCTURAL = "structural" # Derived from mission, created upfront
EMERGENT = "emergent" # Discovered from data patterns
LEARNED = "learned" # Formed through reflection
PINNED = "pinned" # User-defined, persists across refreshes
class MentalModel(BaseModel):
@@ -51,3 +48,50 @@ class MentalModel(BaseModel):
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
class StructuralModelTemplate(BaseModel):
"""
A template for a structural mental model.
Generated by LLM based on the bank's mission. Represents what any agent
with this role would need to track.
"""
id: str = Field(default="", description="Existing model ID to keep, or empty for new models")
name: str = Field(description="Human-readable name")
description: str = Field(description="What this model should track")
initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model")
class StructuralModelDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class EmergentCandidate(BaseModel):
"""
A candidate for promotion to emergent mental model.
Detected through pattern analysis of facts.
"""
name: str = Field(description="Name of the detected pattern/entity")
detection_method: str = Field(description="How this candidate was detected")
mention_count: int = Field(default=0, description="How many times referenced")
entity_id: str | None = Field(default=None, description="Entity ID if detected as entity")
relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)")
class ResearchResult(BaseModel):
"""
Result from the research endpoint.
Contains the answer along with the mental models and facts used.
"""
answer: str = Field(description="The synthesized answer")
mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed")
facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed")
question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)")
@@ -0,0 +1,228 @@
"""
Structural mental model derivation from bank mission.
Structural models are derived from the bank's mission - they represent what
any agent with this role would need to track. For example:
Mission: "Be a PM for engineering team"
Structural models:
- Team Structure (who's on the team, roles)
- Project Overview (current projects, status)
- Processes (how releases work, how decisions are made)
- Key Systems (what we own, dependencies)
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import StructuralModelTemplate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class StructuralDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class StructuralRelevanceResult(BaseModel):
"""Result of evaluating a structural model's relevance to the mission."""
name: str
relevant: bool
reason: str
class StructuralRelevanceResponse(BaseModel):
"""Response from LLM for structural model relevance evaluation."""
models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model")
def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str:
"""Build the prompt for deriving structural models from a mission."""
existing_section = ""
if existing_models:
model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models])
existing_section = f"""
EXISTING STRUCTURAL MODELS:
{model_list}
IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value.
Models not included in your output will be REMOVED.
"""
return f"""Given this agent mission, identify the KEY THINGS to track to achieve it.
MISSION: {mission}
{existing_section}
IMPORTANT CONSTRAINTS:
- Return 0-3 structural models MAXIMUM (less is better!)
- Only include models for SPECIFIC, CONCRETE things the agent needs to track
- Each model must be DIRECTLY tied to achieving the mission
- If the mission is simple, return 0 models (empty array is fine)
- If existing models are provided and you want to keep one, use its EXACT id
- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists)
GOOD examples (specific, actionable):
- Mission: "Be a PM for engineering team""Team Members" (track who's on the team)
- Mission: "Track customer feedback""Customer Issues" (track specific complaints/requests)
- Mission: "Manage project X""Project X Milestones" (track progress)
BAD examples (too generic, don't create these):
- "Processes", "Workflows", "Key Systems", "Important Events"
- "Communication", "Collaboration", "Progress", "Status"
- Generic role-based models not tied to the specific mission
For each model:
1. id: Use EXACT existing id if keeping a model, or leave empty for new models
2. name: Short, specific name (e.g., "Team Members", "Sprint Goals")
3. description: One line describing what to track
4. initial_probes: 2-3 search queries to find relevant information
Return ONLY the models that should exist. Existing models not in your output will be deleted."""
def get_structural_derivation_system_message() -> str:
"""System message for structural model derivation."""
return """You identify the key things to track for a mission. Be VERY selective.
Rules:
- Maximum 3 models (prefer fewer)
- Only SPECIFIC, CONCRETE things - not generic categories
- Each must DIRECTLY help achieve the mission
- Empty array is valid if no models are truly needed
- If existing models are shown and you want to keep one, return its EXACT id
- Never create duplicates - if a similar model exists, keep the existing one
Output JSON with 'templates' array (can be empty)."""
def _normalize_id(text: str) -> str:
"""Normalize a string to a canonical form for comparison.
Removes common suffixes, pluralization, and normalizes separators.
"""
# Lowercase and normalize separators
normalized = text.lower().replace(" ", "-").replace("_", "-")
# Remove common suffixes that indicate the same concept
suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"]
for suffix in suffixes_to_remove:
if normalized.endswith(suffix) and len(normalized) > len(suffix):
normalized = normalized[: -len(suffix)]
return normalized
def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None:
"""Find an existing model ID that is similar to the new ID.
Returns the existing ID if a similar one is found, None otherwise.
"""
if not existing_models:
return None
new_normalized = _normalize_id(new_id)
for model in existing_models:
existing_id = model.get("id", "")
existing_normalized = _normalize_id(existing_id)
# Check if one is a prefix of the other (normalized)
if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized):
return existing_id
# Check if they're the same when normalized
if new_normalized == existing_normalized:
return existing_id
return None
async def derive_structural_models(
llm_config: "LLMConfig",
mission: str,
existing_models: list[dict] | None = None,
) -> tuple[list[StructuralModelTemplate], list[str]]:
"""
Derive structural model templates from a bank's mission.
This combines derivation and evaluation in one call. The LLM sees existing
models and decides which to keep. Any existing model not in the output
will be marked for removal.
Args:
llm_config: LLM configuration for calling the model
mission: The bank's mission (e.g., "Be a PM for engineering team")
existing_models: Optional list of existing model dicts with 'name', 'description', 'id'
Returns:
Tuple of (templates to create/keep, IDs of existing models to remove)
Raises:
Exception: If LLM call fails
"""
prompt = build_structural_derivation_prompt(mission, existing_models)
result = await llm_config.call(
messages=[
{"role": "system", "content": get_structural_derivation_system_message()},
{"role": "user", "content": prompt},
],
response_format=StructuralDerivationResponse,
scope="mental_model_structural_derivation",
)
templates = result.templates
logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models")
# Build set of existing IDs for quick lookup
existing_ids = {m["id"] for m in existing_models} if existing_models else set()
# Process templates: validate IDs, deduplicate, assign stable IDs
processed_templates: list[StructuralModelTemplate] = []
kept_existing_ids: set[str] = set()
for template in templates:
# If LLM returned an ID, check if it's a valid existing ID
if template.id and template.id in existing_ids:
# LLM is keeping an existing model
kept_existing_ids.add(template.id)
processed_templates.append(template)
logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}")
else:
# New model or LLM didn't return a valid ID
# Generate ID from name
generated_id = template.name.lower().replace(" ", "-").replace("_", "-")
# Check for similar existing models to prevent near-duplicates
similar_id = _find_similar_existing_id(generated_id, existing_models)
if similar_id and similar_id not in kept_existing_ids:
# Use the existing similar model instead of creating a new one
logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'")
template.id = similar_id
kept_existing_ids.add(similar_id)
else:
template.id = generated_id
processed_templates.append(template)
# Find existing models to remove (not kept in LLM output)
models_to_remove = []
if existing_models:
for model in existing_models:
if model["id"] not in kept_existing_ids:
logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal")
models_to_remove.append(model["id"])
if models_to_remove:
logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed")
return processed_templates, models_to_remove
@@ -1,14 +0,0 @@
"""
LLM provider implementations.
This package contains concrete implementations of the LLMInterface for various providers.
"""
from .anthropic_llm import AnthropicLLM
from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .mock_llm import MockLLM
from .openai_compatible_llm import OpenAICompatibleLLM
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "MockLLM", "OpenAICompatibleLLM"]
@@ -1,434 +0,0 @@
"""
Anthropic LLM provider using the Anthropic Python SDK.
This provider enables using Claude models from Anthropic with support for:
- Structured JSON output
- Tool/function calling with proper format conversion
- Extended thinking mode
- Retry logic with exponential backoff
"""
import asyncio
import json
import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class AnthropicLLM(LLMInterface):
"""
LLM provider using Anthropic's Claude models.
Supports structured output, tool calling, and extended thinking mode.
Handles format conversion between OpenAI-style messages and Anthropic's format.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
"""
Initialize Anthropic LLM provider.
Args:
provider: Provider name (should be "anthropic").
api_key: Anthropic API key.
base_url: Base URL for the API (optional, uses Anthropic default if empty).
model: Model name (e.g., "claude-sonnet-4-20250514").
reasoning_effort: Reasoning effort level (not used by Anthropic).
timeout: Request timeout in seconds.
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
if not self.api_key:
raise ValueError("API key is required for Anthropic provider")
# Import and initialize Anthropic client
try:
from anthropic import AsyncAnthropic
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if timeout:
client_kwargs["timeout"] = timeout
self._client = AsyncAnthropic(**client_kwargs)
logger.info(f"Anthropic client initialized for model: {self.model}")
except ImportError as e:
raise RuntimeError("Anthropic SDK not installed. Run: uv add anthropic or pip install anthropic") from e
async def verify_connection(self) -> None:
"""
Verify that the Anthropic provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
max_retries=0,
)
logger.info("Anthropic connection verified successfully")
except Exception as e:
logger.error(f"Anthropic connection verification failed: {e}")
raise RuntimeError(f"Failed to verify Anthropic connection: {e}") from e
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make an LLM API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (not supported by Anthropic).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with token counts.
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
from anthropic import APIConnectionError, APIStatusError, RateLimitError
start_time = time.time()
# Convert OpenAI-style messages to Anthropic format
system_prompt = None
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
if system_prompt:
system_prompt += "\n\n" + content
else:
system_prompt = content
else:
anthropic_messages.append({"role": role, "content": content})
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if system_prompt:
system_prompt += schema_msg
else:
system_prompt = schema_msg
# Prepare parameters
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
"max_tokens": max_completion_tokens if max_completion_tokens is not None else 4096,
}
if system_prompt:
call_params["system"] = system_prompt
if temperature is not None:
call_params["temperature"] = temperature
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
# Anthropic response content is a list of blocks
content = ""
for block in response.content:
if block.type == "text":
content += block.text
if response_format is not None:
# Models may wrap JSON in markdown code blocks
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content if markdown stripping failed
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Record metrics and log slow calls
duration = time.time() - start_time
input_tokens = response.usage.input_tokens or 0 if response.usage else 0
output_tokens = response.usage.output_tokens or 0 if response.usage else 0
total_tokens = input_tokens + output_tokens
# Record LLM metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return result, token_usage
return result
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning("Anthropic returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Anthropic returned invalid JSON after {max_retries + 1} attempts")
raise
except (APIConnectionError, RateLimitError, APIStatusError) as e:
# Fast fail on 401/403
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
logger.error(f"Anthropic auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
last_exception = e
if attempt < max_retries:
# Check if it's a rate limit or server error
should_retry = isinstance(e, (APIConnectionError, RateLimitError)) or (
isinstance(e, APIStatusError) and e.status_code >= 500
)
if should_retry:
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
continue
logger.error(f"Anthropic API error after {max_retries + 1} attempts: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during Anthropic call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Anthropic call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from anthropic import APIConnectionError, APIStatusError
start_time = time.time()
# Convert OpenAI tool format to Anthropic format
anthropic_tools = []
for tool in tools:
func = tool.get("function", {})
anthropic_tools.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {"type": "object", "properties": {}}),
}
)
# Convert messages - handle tool results
system_prompt = None
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt = (system_prompt + "\n\n" + content) if system_prompt else content
elif role == "tool":
# Anthropic uses tool_result blocks
anthropic_messages.append(
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": msg.get("tool_call_id", ""), "content": content}
],
}
)
elif role == "assistant" and msg.get("tool_calls"):
# Convert assistant tool calls
tool_use_blocks = []
for tc in msg["tool_calls"]:
tool_use_blocks.append(
{
"type": "tool_use",
"id": tc.get("id", ""),
"name": tc.get("function", {}).get("name", ""),
"input": json.loads(tc.get("function", {}).get("arguments", "{}")),
}
)
anthropic_messages.append({"role": "assistant", "content": tool_use_blocks})
else:
anthropic_messages.append({"role": role, "content": content})
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
"tools": anthropic_tools,
"max_tokens": max_completion_tokens or 4096,
}
if system_prompt:
call_params["system"] = system_prompt
if temperature is not None:
call_params["temperature"] = temperature
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
# Extract content and tool calls
content_parts = []
tool_calls: list[LLMToolCall] = []
for block in response.content:
if block.type == "text":
content_parts.append(block.text)
elif block.type == "tool_use":
tool_calls.append(LLMToolCall(id=block.id, name=block.name, arguments=block.input or {}))
content = "".join(content_parts) if content_parts else None
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
input_tokens = response.usage.input_tokens or 0
output_tokens = response.usage.output_tokens or 0
# Record metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except (APIConnectionError, APIStatusError) as e:
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
if last_exception:
raise last_exception
raise RuntimeError("Anthropic tool call failed")
async def cleanup(self) -> None:
"""Clean up resources (close Anthropic client connections)."""
if hasattr(self, "_client") and self._client:
await self._client.close()
@@ -1,352 +0,0 @@
"""
Claude Code LLM provider using Claude Agent SDK.
This provider enables using Claude Pro/Max subscriptions for API calls
via the Claude CLI authentication. It uses the Claude Agent SDK which
automatically handles authentication via `claude auth login` credentials.
"""
import asyncio
import json
import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class ClaudeCodeLLM(LLMInterface):
"""
LLM provider using Claude Code authentication.
Authenticates using Claude Pro/Max credentials via `claude auth login`
and makes API calls through the Claude Agent SDK.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, uses CLI auth
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""Initialize Claude Code LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Verify Claude Agent SDK is available
try:
self._verify_claude_code_available()
logger.info("Claude Code: Using Claude Agent SDK (authentication via claude auth login)")
except Exception as e:
raise RuntimeError(
f"Failed to initialize Claude Code provider: {e}\n\n"
"To set up Claude Code authentication:\n"
"1. Install Claude Code CLI: npm install -g @anthropics/claude-code\n"
"2. Login with your Pro/Max plan: claude auth login\n"
"3. Verify authentication: claude --version\n\n"
"Or use a different provider (anthropic, openai, gemini) with API keys."
) from e
# Metrics collector is imported at module level
def _verify_claude_code_available(self) -> None:
"""
Verify that Claude Agent SDK can be imported and is properly configured.
Raises:
ImportError: If Claude Agent SDK is not installed.
RuntimeError: If Claude Code is not authenticated.
"""
try:
# Import Claude Agent SDK
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
logger.debug("Claude Agent SDK imported successfully")
except ImportError as e:
raise ImportError(
"Claude Agent SDK not installed. Run: uv add claude-agent-sdk or pip install claude-agent-sdk"
) from e
# SDK will automatically check for authentication when first used
# No need to verify here - let it fail gracefully on first call with helpful error
async def verify_connection(self) -> None:
"""
Verify that the Claude Code provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
max_retries=0,
)
logger.info("Claude Code connection verified successfully")
except Exception as e:
logger.error(f"Claude Code connection verification failed: {e}")
raise RuntimeError(f"Failed to verify Claude Code connection: {e}") from e
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make an LLM API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response (ignored by Claude Agent SDK).
temperature: Sampling temperature (ignored by Claude Agent SDK).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (not supported).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with estimated token counts.
Raises:
OutputTooLongError: If output exceeds token limits (not supported by Claude Agent SDK).
Exception: Re-raises API errors after retries exhausted.
"""
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
start_time = time.time()
# Build system prompt
system_prompt = ""
user_content = ""
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt += ("\n\n" + content) if system_prompt else content
elif role == "user":
user_content += ("\n\n" + content) if user_content else content
elif role == "assistant":
# Claude Agent SDK doesn't support multi-turn easily in query()
# For now, prepend assistant messages to user content
user_content += f"\n\n[Previous assistant response: {content}]"
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_instruction = (
f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}\n\n"
"Respond with ONLY the JSON, no markdown formatting."
)
user_content += schema_instruction
# Configure SDK options
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
allowed_tools=[], # Disable tools for standard LLM calls
)
# Call Claude Agent SDK
last_exception = None
for attempt in range(max_retries + 1):
try:
# Collect streaming response
full_text = ""
async for message in query(prompt=user_content, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
clean_text = full_text
if "```json" in full_text:
clean_text = full_text.split("```json")[1].split("```")[0].strip()
elif "```" in full_text:
clean_text = full_text.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_text)
except json.JSONDecodeError as e:
logger.warning(f"Claude Code JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
continue
raise
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = full_text
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
# Estimate token usage (Claude Agent SDK doesn't report exact counts)
# Use character count / 4 as rough estimate (1 token ≈ 4 characters)
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(full_text) // 4
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=estimated_input,
output_tokens=estimated_output,
success=True,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=estimated_input,
output_tokens=estimated_output,
total_tokens=estimated_input + estimated_output,
)
return result, token_usage
return result
except Exception as e:
last_exception = e
# Check for authentication errors
error_str = str(e).lower()
if "auth" in error_str or "login" in error_str or "credential" in error_str:
logger.error(f"Claude Code authentication error: {e}")
raise RuntimeError(
f"Claude Code authentication failed: {e}\n\n"
"Run 'claude auth login' to authenticate with Claude Pro/Max."
) from e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Claude Code error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Claude Code error after {max_retries + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Claude Code call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Note: This is a simplified implementation. Full tool support would require
integrating with Claude Agent SDK's tool system.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
# For now, use regular call without tools
# Full implementation would require mapping OpenAI tool format to Claude Agent SDK tools
logger.warning(
"Claude Code provider does not fully support tool calling yet. Falling back to regular text completion."
)
result = await self.call(
messages=messages,
response_format=None,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
return_usage=True,
)
if isinstance(result, tuple):
text, usage = result
return LLMToolCallResult(
content=text,
tool_calls=[],
finish_reason="stop",
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
else:
# Fallback if return_usage didn't work as expected
return LLMToolCallResult(
content=str(result),
tool_calls=[],
finish_reason="stop",
input_tokens=0,
output_tokens=0,
)
async def cleanup(self) -> None:
"""Clean up resources (no HTTP client to close for Claude Agent SDK)."""
pass
@@ -1,527 +0,0 @@
"""
OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
"""
import asyncio
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Any
import httpx
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json
and makes API calls to chatgpt.com/backend-api/codex/responses.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, reads from ~/.codex/auth.json
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""Initialize Codex LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Load Codex OAuth credentials
try:
self.access_token, self.account_id = self._load_codex_auth()
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
except Exception as e:
raise RuntimeError(
f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n"
"To set up Codex authentication:\n"
"1. Install Codex CLI: npm install -g @openai/codex\n"
"2. Login: codex auth login\n"
"3. Verify: ls ~/.codex/auth.json\n\n"
"Or use a different provider (openai, anthropic, gemini) with API keys."
) from e
# Use ChatGPT backend API endpoint
if not self.base_url:
self.base_url = "https://chatgpt.com/backend-api"
# Normalize model name (strip openai/ prefix if present)
if self.model.startswith("openai/"):
self.model = self.model[len("openai/") :]
# Map reasoning effort to Codex reasoning summary format
# Codex supports: "auto", "concise", "detailed"
self.reasoning_summary = self._map_reasoning_effort(reasoning_effort)
# HTTP client for SSE streaming
self._client = httpx.AsyncClient(timeout=120.0)
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from ~/.codex/auth.json.
Returns:
Tuple of (access_token, account_id).
Raises:
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = Path.home() / ".codex" / "auth.json"
if not auth_file.exists():
raise FileNotFoundError(
f"Codex auth file not found: {auth_file}\nRun 'codex auth login' to authenticate with ChatGPT Plus/Pro."
)
with open(auth_file) as f:
data = json.load(f)
# Validate auth structure
auth_mode = data.get("auth_mode")
if auth_mode != "chatgpt":
raise ValueError(f"Expected auth_mode='chatgpt', got: {auth_mode}")
tokens = data.get("tokens", {})
access_token = tokens.get("access_token")
account_id = tokens.get("account_id")
if not access_token:
raise ValueError("No access_token found in Codex auth file. Run 'codex auth login' again.")
return access_token, account_id
def _map_reasoning_effort(self, effort: str) -> str:
"""
Map standard reasoning effort to Codex reasoning summary format.
Args:
effort: Standard effort level ("low", "medium", "high", "xhigh").
Returns:
Codex reasoning summary: "concise", "detailed", or "auto".
"""
mapping = {
"low": "concise",
"medium": "auto",
"high": "detailed",
"xhigh": "detailed",
}
return mapping.get(effort.lower(), "auto")
async def verify_connection(self) -> None:
"""Verify Codex connection by making a simple test call."""
try:
logger.info(f"Verifying Codex LLM: model={self.model}, account={self.account_id}...")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=10,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
)
logger.info(f"Codex LLM verified: {self.model}")
except Exception as e:
raise RuntimeError(f"Codex LLM connection verification failed for {self.model}: {e}") from e
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Make API call to Codex backend with SSE streaming."""
start_time = time.time()
# Prepare system instructions
system_instruction = ""
user_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction += ("\n\n" + content) if system_instruction else content
else:
user_messages.append(msg)
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
system_instruction += schema_msg
# Build Codex request payload
payload = {
"model": self.model,
"instructions": system_instruction,
"input": [
{
"type": "message",
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
}
for msg in user_messages
],
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": True,
"reasoning": {"summary": self.reasoning_summary},
"store": False, # Codex uses stateless mode
"stream": True, # SSE streaming
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
url = f"{self.base_url}/codex/responses"
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError as e:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
continue
raise
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0, # Codex doesn't report token counts in SSE
output_tokens=0,
success=True,
)
if return_usage:
# Codex doesn't provide token counts, estimate based on content
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
token_usage = TokenUsage(
input_tokens=estimated_input,
output_tokens=estimated_output,
total_tokens=estimated_input + estimated_output,
)
return result, token_usage
return result
except httpx.HTTPStatusError as e:
last_exception = e
status_code = e.response.status_code
# Fast fail on auth errors
if status_code in (401, 403):
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
raise RuntimeError(
"Codex authentication failed. Your OAuth token may have expired.\n"
"Run 'codex auth login' to re-authenticate."
) from e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1})")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Codex HTTP error after {max_retries + 1} attempts: {e}")
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
raise
except Exception as e:
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Codex call failed after all retries")
async def _parse_sse_stream(self, response: httpx.Response) -> str:
"""
Parse Server-Sent Events (SSE) stream from Codex API.
Args:
response: HTTP response with SSE stream.
Returns:
Extracted text content from stream.
"""
full_text = ""
event_type = None
async for line in response.aiter_lines():
if not line:
continue
# Track event type
if line.startswith("event: "):
event_type = line[7:]
# Parse data
elif line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
# Extract content based on event type
if event_type == "response.text.delta" and "delta" in data:
full_text += data["delta"]
elif event_type == "response.content_part.delta" and "delta" in data:
full_text += data["delta"]
# Check for item content
elif "item" in data:
item = data["item"]
if "content" in item:
content = item["content"]
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and "text" in part:
full_text += part["text"]
elif isinstance(content, str):
full_text += content
except json.JSONDecodeError:
# Skip malformed JSON events
pass
return full_text
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make API call with tool calling support.
Note: This is a basic implementation. Full tool calling support for Codex
may require additional SSE event parsing.
"""
start_time = time.time()
# Prepare system instructions
system_instruction = ""
user_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction += ("\n\n" + content) if system_instruction else content
elif role == "tool":
# Handle tool results
user_messages.append(
{
"type": "message",
"role": "user",
"content": f"Tool result: {content}",
}
)
else:
user_messages.append(
{
"type": "message",
"role": role,
"content": content,
}
)
# Convert tools to Codex format
codex_tools = []
for tool in tools:
func = tool.get("function", {})
codex_tools.append(
{
"type": "function",
"function": {
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
},
}
)
payload = {
"model": self.model,
"instructions": system_instruction,
"input": user_messages,
"tools": codex_tools,
"tool_choice": tool_choice,
"parallel_tool_calls": True,
"reasoning": {"summary": self.reasoning_summary},
"store": False,
"stream": True,
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
url = f"{self.base_url}/codex/responses"
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Parse SSE for tool calls and content
content, tool_calls = await self._parse_sse_tool_stream(response)
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0,
output_tokens=0,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
input_tokens=0,
output_tokens=0,
)
except Exception as e:
logger.error(f"Codex tool call error: {e}")
raise
async def _parse_sse_tool_stream(self, response: httpx.Response) -> tuple[str | None, list[LLMToolCall]]:
"""
Parse SSE stream for tool calls and content.
Returns:
Tuple of (content, tool_calls).
"""
content = ""
tool_calls: list[LLMToolCall] = []
event_type = None
async for line in response.aiter_lines():
if not line:
continue
if line.startswith("event: "):
event_type = line[7:]
elif line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
# Extract text content
if event_type == "response.text.delta" and "delta" in data:
content += data["delta"]
# Extract tool calls
elif event_type == "response.function_call_arguments.delta":
# Handle tool call events (implementation depends on actual Codex SSE format)
pass
except json.JSONDecodeError:
pass
return content if content else None, tool_calls
async def cleanup(self) -> None:
"""Clean up HTTP client."""
await self._client.aclose()
@@ -1,502 +0,0 @@
"""
Google Gemini/VertexAI LLM provider.
This provider supports both:
1. Gemini API (api.generativeai.google.com) with API key authentication
2. Vertex AI with service account or Application Default Credentials (ADC)
"""
import asyncio
import json
import logging
import os
import time
from typing import Any
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# Vertex AI imports (optional)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
except ImportError:
VERTEXAI_AVAILABLE = False
class GeminiLLM(LLMInterface):
"""
LLM provider for Google Gemini and Vertex AI.
Supports:
- Gemini API: provider="gemini", requires api_key
- Vertex AI: provider="vertexai", requires project_id and region, uses ADC or service account
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""Initialize Gemini/VertexAI LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self._client = None
self._is_vertexai = self.provider == "vertexai"
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
self._init_gemini()
def _init_gemini(self) -> None:
"""Initialize Gemini API client."""
if not self.api_key:
raise ValueError("Gemini provider requires api_key")
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini API: model={self.model}")
def _init_vertexai(self, **kwargs: Any) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
# Extract Vertex AI config from kwargs
project_id = kwargs.get("vertexai_project_id")
region = kwargs.get("vertexai_region", "us-central1")
service_account_key = kwargs.get("vertexai_service_account_key")
credentials = kwargs.get("vertexai_credentials") # Pre-loaded credentials object
if not project_id:
raise ValueError(
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
"Set it to your GCP project ID."
)
auth_method = "ADC"
# Use pre-loaded credentials if provided (passed from LLMProvider)
if credentials is not None:
auth_method = "service_account"
# Otherwise, load explicit service account credentials if path provided
elif service_account_key:
if not VERTEXAI_AVAILABLE:
raise ValueError(
"Vertex AI service account auth requires 'google-auth' package. "
"Install with: pip install google-auth"
)
credentials = service_account.Credentials.from_service_account_file(
service_account_key,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
auth_method = "service_account"
logger.info(f"Vertex AI: Using service account key: {service_account_key}")
# Strip google/ prefix from model name — native SDK uses bare names
# e.g. "google/gemini-2.0-flash-lite-001" -> "gemini-2.0-flash-lite-001"
if self.model.startswith("google/"):
self.model = self.model[len("google/") :]
# Create Vertex AI client
client_kwargs: dict[str, Any] = {
"vertexai": True,
"project": project_id,
"location": region,
}
if credentials is not None:
client_kwargs["credentials"] = credentials
self._client = genai.Client(**client_kwargs)
logger.info(f"Vertex AI: project={project_id}, region={region}, model={self.model}, auth={auth_method}")
async def verify_connection(self) -> None:
"""
Verify that the Gemini/VertexAI provider is configured correctly.
Raises:
RuntimeError: If the connection test fails.
"""
try:
logger.info(f"Verifying {self.provider.upper()}: model={self.model}...")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
)
logger.info(f"{self.provider.upper()} connection verified successfully")
except Exception as e:
raise RuntimeError(f"Failed to verify {self.provider.upper()} connection: {e}") from e
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make a Gemini/VertexAI API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response (not supported by Gemini).
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (not supported by Gemini).
return_usage: If True, return tuple (result, TokenUsage).
Returns:
If return_usage=False: Parsed response if response_format provided, else text.
If return_usage=True: Tuple of (result, TokenUsage).
"""
start_time = time.time()
# Convert OpenAI-style messages to Gemini format
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
if system_instruction:
system_instruction += "\n\n" + content
else:
system_instruction = content
elif role == "assistant":
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if system_instruction:
system_instruction += schema_msg
else:
system_instruction = schema_msg
# Build generation config
config_kwargs: dict[str, Any] = {}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
if temperature is not None:
config_kwargs["temperature"] = temperature
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
)
content = response.text
# Handle empty response
if content is None:
block_reason = None
if hasattr(response, "candidates") and response.candidates:
candidate = response.candidates[0]
if hasattr(candidate, "finish_reason"):
block_reason = candidate.finish_reason
if attempt < max_retries:
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts")
# Parse structured output if requested
if response_format is not None:
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Extract token usage
input_tokens = 0
output_tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage = response.usage_metadata
input_tokens = usage.prompt_token_count or 0
output_tokens = usage.candidates_token_count or 0
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Log slow calls
if duration > 10.0 and input_tokens > 0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
)
return result, token_usage
return result
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning("Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts")
raise
except genai_errors.APIError as e:
# Fast fail on auth errors - these won't recover with retries
if e.code in (401, 403):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on retryable errors (rate limits, server errors, client errors)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
else:
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
raise
else:
logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Gemini call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make a Gemini/VertexAI API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens (not supported by Gemini).
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (Gemini uses "auto" only).
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
# Convert tools to Gemini format
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
# Convert messages
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
elif role == "tool":
# Gemini uses function_response
gemini_contents.append(
genai_types.Content(
role="user",
parts=[
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=msg.get("name", ""),
response={"result": content},
)
)
],
)
)
elif role == "assistant":
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
config_kwargs: dict[str, Any] = {"tools": gemini_tools}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=config,
)
# Extract content and tool calls
content = None
tool_calls: list[LLMToolCall] = []
if response.candidates and response.candidates[0].content:
parts = response.candidates[0].content.parts
if parts:
for part in parts:
if hasattr(part, "text") and part.text:
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
)
)
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
input_tokens = 0
output_tokens = 0
if response.usage_metadata:
input_tokens = response.usage_metadata.prompt_token_count or 0
output_tokens = response.usage_metadata.candidates_token_count or 0
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except genai_errors.APIError as e:
# Fast fail on auth errors
if e.code in (401, 403):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on retryable errors
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini tool call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Gemini tool call failed")
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
pass
@@ -1,234 +0,0 @@
"""
Mock LLM provider for testing.
This provider allows tests to record LLM calls and return configurable mock responses
without making actual API calls to external LLM services.
"""
import logging
from typing import Any
from ..llm_interface import LLMInterface
from ..response_models import LLMToolCall, LLMToolCallResult, TokenUsage
logger = logging.getLogger(__name__)
class MockLLM(LLMInterface):
"""
Mock LLM provider for testing.
This provider records all calls and returns configurable mock responses,
enabling tests to verify LLM interactions without making real API calls.
Example:
# Create mock provider
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
# Set mock response
mock_llm.set_mock_response({"answer": "test"})
# Make calls
result = await mock_llm.call(
messages=[{"role": "user", "content": "test"}],
response_format=MyResponseModel
)
# Verify calls
calls = mock_llm.get_mock_calls()
assert len(calls) == 1
assert calls[0]["scope"] == "memory"
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""
Initialize mock LLM provider.
Args:
provider: Provider name (should be "mock").
api_key: Not used for mock provider.
base_url: Not used for mock provider.
model: Model name for tracking.
reasoning_effort: Not used for mock provider.
**kwargs: Additional parameters (not used).
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Storage for test verification
self._mock_calls: list[dict] = []
self._mock_response: Any = None
async def verify_connection(self) -> None:
"""
Verify mock provider (always succeeds).
Mock provider doesn't need connection verification since it doesn't
make real API calls.
"""
logger.debug("Mock LLM: connection verification (always succeeds)")
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make a mock LLM API call.
Records the call for test verification and returns the configured mock response.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
max_backoff: Not used in mock.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Not used in mock.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with mock token counts.
"""
# Record the call for test verification
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"response_format": response_format.__name__
if response_format and hasattr(response_format, "__name__")
else str(response_format),
"scope": scope,
}
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
# Return mock response
if self._mock_response is not None:
result = self._mock_response
elif response_format is not None:
# Try to create a minimal valid instance of the response format
try:
# For Pydantic models, try to create with minimal valid data
result = {"mock": True}
except Exception:
result = {"mock": True}
else:
result = "mock response"
if return_usage:
token_usage = TokenUsage(input_tokens=10, output_tokens=5, total_tokens=15)
return result, token_usage
return result
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make a mock LLM API call with tool/function calling support.
Records the call for test verification and returns the configured mock response.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
max_backoff: Not used in mock.
tool_choice: Not used in mock.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
# Record the call for test verification
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
}
self._mock_calls.append(call_record)
if self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
return self._mock_response
# Allow setting just tool calls as a list
if isinstance(self._mock_response, list):
return LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(self._mock_response)
],
finish_reason="tool_calls",
)
return LLMToolCallResult(content="mock response", finish_reason="stop")
async def cleanup(self) -> None:
"""Clean up resources (no-op for mock provider)."""
pass
def set_mock_response(self, response: Any) -> None:
"""
Set the response to return from mock calls.
Args:
response: The response to return. Can be:
- A dict/Pydantic model for regular calls
- An LLMToolCallResult for tool calls
- A list of tool call dicts for tool calls
- Any other value to return as-is
"""
self._mock_response = response
def get_mock_calls(self) -> list[dict]:
"""
Get the list of recorded mock calls.
Returns:
List of call records, each containing:
- provider: Provider name
- model: Model name
- messages: Messages sent
- response_format/tools: Format or tools used
- scope: Call scope
"""
return self._mock_calls
def clear_mock_calls(self) -> None:
"""Clear the recorded mock calls."""
self._mock_calls = []
@@ -1,745 +0,0 @@
"""
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, and LMStudio.
This provider handles all OpenAI API-compatible models including:
- OpenAI: GPT-4, GPT-4o, GPT-5, o1, o3 (reasoning models)
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API support
- LMStudio: Local models with OpenAI-compatible API
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
- Strict JSON schema enforcement (OpenAI)
- Provider-specific parameters (Groq seed, service tier)
- Native Ollama streaming for better structured output
- Automatic token limit handling per model family
"""
import asyncio
import json
import logging
import os
import re
import time
from typing import Any
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# Seed applied to every Groq request for deterministic behavior
DEFAULT_LLM_SEED = 4242
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
Supports:
- OpenAI: Standard models (GPT-4, GPT-4o) and reasoning models (o1, o3, GPT-5)
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API for better structured output
- LMStudio: Local models with OpenAI-compatible API
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float | None = None,
groq_service_tier: str | None = None,
**kwargs: Any,
):
"""
Initialize OpenAI-compatible LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama", "lmstudio").
api_key: API key (optional for ollama/lmstudio).
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
timeout: Request timeout in seconds (uses env var or 300s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Validate provider
valid_providers = ["openai", "groq", "ollama", "lmstudio"]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
# Set default base URLs
if not self.base_url:
if self.provider == "groq":
self.base_url = "https://api.groq.com/openai/v1"
elif self.provider == "ollama":
self.base_url = "http://localhost:11434/v1"
elif self.provider == "lmstudio":
self.base_url = "http://localhost:1234/v1"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if self.provider in ("openai", "groq") and not self.api_key:
raise ValueError(f"API key is required for {self.provider}")
# Groq service tier configuration
self.groq_service_tier = groq_service_tier or os.getenv("HINDSIGHT_API_LLM_GROQ_SERVICE_TIER", "auto")
# Get timeout config
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
# Create OpenAI client
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if self.timeout:
client_kwargs["timeout"] = self.timeout
self._client = AsyncOpenAI(**client_kwargs)
logger.info(
f"OpenAI-compatible client initialized: provider={self.provider}, model={self.model}, "
f"base_url={self.base_url or 'default'}"
)
async def verify_connection(self) -> None:
"""
Verify that the provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
logger.info(f"Verifying connection: {self.provider}/{self.model}")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
)
logger.info(f"Connection verified: {self.provider}/{self.model}")
except Exception as e:
raise RuntimeError(f"Connection verification failed for {self.provider}/{self.model}: {e}") from e
def _supports_reasoning_model(self) -> bool:
"""Check if the current model is a reasoning model (o1, o3, GPT-5, DeepSeek)."""
model_lower = self.model.lower()
return any(x in model_lower for x in ["gpt-5", "o1", "o3", "deepseek"])
def _get_max_reasoning_tokens(self) -> int | None:
"""Get max reasoning tokens for reasoning models."""
model_lower = self.model.lower()
# GPT-4 and GPT-4.1 models have different caps
if any(x in model_lower for x in ["gpt-4.1", "gpt-4-"]):
return 32000
elif "gpt-4o" in model_lower:
return 16384
return None
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make an LLM API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with token counts.
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
# Handle Ollama with native API for structured output (better schema enforcement)
if self.provider == "ollama" and response_format is not None:
return await self._call_ollama_native(
messages=messages,
response_format=response_format,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=skip_validation,
scope=scope,
return_usage=return_usage,
)
start_time = time.time()
# Build call parameters
call_params: dict[str, Any] = {
"model": self.model,
"messages": messages,
}
# Check if model supports reasoning parameter
is_reasoning_model = self._supports_reasoning_model()
# Apply model-specific token limits
if max_completion_tokens is not None:
max_tokens_cap = self._get_max_reasoning_tokens()
if max_tokens_cap and max_completion_tokens > max_tokens_cap:
max_completion_tokens = max_tokens_cap
# For reasoning models, enforce minimum to ensure space for reasoning + output
if is_reasoning_model and max_completion_tokens < 16000:
max_completion_tokens = 16000
call_params["max_completion_tokens"] = max_completion_tokens
# Temperature - reasoning models don't support custom temperature
if temperature is not None and not is_reasoning_model:
call_params["temperature"] = temperature
# Set reasoning_effort for reasoning models
if is_reasoning_model:
call_params["reasoning_effort"] = self.reasoning_effort
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
extra_body: dict[str, Any] = {}
# Add service_tier if configured
if self.groq_service_tier:
extra_body["service_tier"] = self.groq_service_tier
# Add reasoning parameters for reasoning models
if is_reasoning_model:
extra_body["include_reasoning"] = False
if extra_body:
call_params["extra_body"] = extra_body
# Prepare response format ONCE before retry loop
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
call_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": True,
"schema": schema,
},
}
else:
# Soft enforcement: add schema to prompt and use json_object mode
if schema is not None:
schema_msg = (
f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
)
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] += schema_msg
elif call_params["messages"]:
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
call_params["response_format"] = {"type": "json_object"}
last_exception = None
for attempt in range(max_retries + 1):
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
content = response.choices[0].message.content
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
if content:
original_len = len(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
# For local models, they may wrap JSON in markdown code blocks
if self.provider in ("lmstudio", "ollama"):
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content
json_data = json.loads(content)
else:
# Log raw LLM response for debugging JSON parse issues
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
# Truncate content for logging
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: {self.provider}/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}\n"
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
)
# Retry on JSON parse errors
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
raise
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
response = await self._client.chat.completions.create(**call_params)
result = response.choices[0].message.content
# Record token usage metrics
duration = time.time() - start_time
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
total_tokens = usage.total_tokens or 0 if usage else 0
# Record LLM metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
cached_tokens = 0
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"total_tokens={total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return result, token_usage
return result
except LengthFinishReasonError as e:
logger.warning(f"LLM output exceeded token limits: {str(e)}")
raise OutputTooLongError(
"LLM output exceeded token limits. Input may need to be split into smaller chunks."
) from e
except APIConnectionError as e:
last_exception = e
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(f"APIConnectionError (HTTP {status_code}), attempt {attempt + 1}: {str(e)[:200]}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Connection error after {max_retries + 1} attempts: {str(e)}")
raise
except APIStatusError as e:
# Fast fail only on 401 (unauthorized) and 403 (forbidden)
if e.status_code in (401, 403):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
# Handle tool_use_failed error - model outputted in tool call format
if e.status_code == 400 and response_format is not None:
try:
error_body = e.body if hasattr(e, "body") else {}
if isinstance(error_body, dict):
error_info: dict[str, Any] = error_body.get("error") or {}
if error_info.get("code") == "tool_use_failed":
failed_gen = error_info.get("failed_generation", "")
if failed_gen:
# Parse tool call format and convert to expected format
tool_call = json.loads(failed_gen)
tool_name = tool_call.get("name", "")
tool_args = tool_call.get("arguments", {})
converted = {"actions": [{"tool": tool_name, **tool_args}]}
if skip_validation:
result = converted
else:
result = response_format.model_validate(converted)
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0,
output_tokens=0,
success=True,
)
if return_usage:
return result, TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
return result
except (json.JSONDecodeError, KeyError, TypeError):
pass # Failed to parse tool_use_failed, continue with normal retry
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
sleep_time = backoff + jitter
await asyncio.sleep(sleep_time)
else:
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
raise
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("LLM call failed after all retries with no exception captured")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
# Build call parameters
call_params: dict[str, Any] = {
"model": self.model,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
call_params["temperature"] = temperature
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.chat.completions.create(**call_params)
message = response.choices[0].message
finish_reason = response.choices[0].finish_reason
# Extract tool calls if present
tool_calls: list[LLMToolCall] = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments) if tc.function.arguments else {}
except json.JSONDecodeError:
args = {"_raw": tc.function.arguments}
tool_calls.append(LLMToolCall(id=tc.id, name=tc.function.name, arguments=args))
content = message.content
# Record metrics
duration = time.time() - start_time
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
except APIStatusError as e:
if e.status_code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("Tool call failed after all retries")
async def _call_ollama_native(
self,
messages: list[dict[str, str]],
response_format: Any,
max_completion_tokens: int | None,
temperature: float | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
skip_validation: bool,
scope: str = "memory",
return_usage: bool = False,
) -> Any:
"""
Call Ollama using native API with JSON schema enforcement.
Ollama's native API supports passing a full JSON schema in the 'format' parameter,
which provides better structured output control than the OpenAI-compatible API.
"""
start_time = time.time()
# Get the JSON schema from the Pydantic model
schema = response_format.model_json_schema() if hasattr(response_format, "model_json_schema") else None
# Build the base URL for Ollama's native API
# Default OpenAI-compatible URL is http://localhost:11434/v1
# Native API is at http://localhost:11434/api/chat
base_url = self.base_url or "http://localhost:11434/v1"
if base_url.endswith("/v1"):
native_url = base_url[:-3] + "/api/chat"
else:
native_url = base_url.rstrip("/") + "/api/chat"
# Build request payload
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": False,
}
# Add schema as format parameter for structured output
if schema:
payload["format"] = schema
# Add optional parameters with optimized defaults for Ollama
options: dict[str, Any] = {
"num_ctx": 16384, # 16k context window for larger prompts
"num_batch": 512, # Optimal batch size for prompt processing
}
if max_completion_tokens:
options["num_predict"] = max_completion_tokens
if temperature is not None:
options["temperature"] = temperature
payload["options"] = options
last_exception = None
async with httpx.AsyncClient(timeout=300.0) as client:
for attempt in range(max_retries + 1):
try:
response = await client.post(native_url, json=payload)
response.raise_for_status()
result = response.json()
content = result.get("message", {}).get("content", "")
# Parse JSON response
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
# Extract token usage from Ollama response
duration = time.time() - start_time
input_tokens = result.get("prompt_eval_count", 0) or 0
output_tokens = result.get("eval_count", 0) or 0
total_tokens = input_tokens + output_tokens
# Record LLM metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Validate against Pydantic model or return raw JSON
if skip_validation:
validated_result = json_data
else:
validated_result = response_format.model_validate(json_data)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return validated_result, token_usage
return validated_result
except httpx.HTTPStatusError as e:
last_exception = e
if attempt < max_retries:
logger.warning(
f"Ollama HTTP error (attempt {attempt + 1}/{max_retries + 1}): {e.response.status_code}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Ollama HTTP error after {max_retries + 1} attempts: {e}")
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Ollama connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Ollama connection error after {max_retries + 1} attempts: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error during Ollama call: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Ollama call failed after all retries")
async def cleanup(self) -> None:
"""Clean up resources (close OpenAI client connections)."""
if hasattr(self, "_client") and self._client:
await self._client.close()
@@ -4,15 +4,17 @@ Reflect agent module for agentic reflection with tools.
The reflect agent uses an iterative loop with tools to:
1. Lookup mental models (existing knowledge)
2. Recall facts (semantic + temporal search)
3. Expand memories (get chunk/document context)
3. Learn new insights (create/update mental models)
4. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .models import ReflectAction, ReflectActionBatch
from .models import MentalModelInput, ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectAction",
"ReflectActionBatch",
"MentalModelInput",
]
File diff suppressed because it is too large Load Diff
@@ -7,28 +7,51 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class ObservationSection(BaseModel):
"""A section within an observation with its supporting memories."""
class MentalModelObservation(BaseModel):
"""An observation within a mental model with its supporting memories."""
title: str = Field(description="Section header (can be empty for intro)")
text: str = Field(description="Section content - no headers, use lists/tables/bold")
title: str = Field(description="Observation header (can be empty for intro)")
text: str = Field(description="Observation content - no headers, use lists/tables/bold")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
class MentalModelInput(BaseModel):
"""Input for the learn tool to create a mental model placeholder.
The agent only specifies name and description - the actual content/observations
are generated during refresh, similar to pinned models.
"""
name: str = Field(description="Human-readable name for the mental model")
description: str = Field(description="What to track - used as prompt for content generation during refresh")
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
class AnswerSection(BaseModel):
"""A section of the answer with its supporting evidence (DEPRECATED)."""
title: str = Field(description="Section header/title")
text: str = Field(description="Section content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
class ReflectAction(BaseModel):
"""Single action the reflect agent can take."""
tool: Literal["list_observations", "get_observation", "recall", "expand", "done"] = Field(
description="Tool to invoke: list_observations, get_observation, recall, expand, or done"
tool: Literal["list_mental_models", "get_mental_model", "recall", "learn", "expand", "done"] = Field(
description="Tool to invoke: list_mental_models, get_mental_model, recall, learn, expand, or done"
)
# Tool-specific parameters
observation_id: str | None = Field(default=None, description="Observation ID for get_observation")
model_id: str | None = Field(default=None, description="Mental model ID for get_mental_model")
query: str | None = Field(default=None, description="Search query for recall")
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
mental_model: MentalModelInput | None = Field(default=None, description="Mental model to create/update for learn")
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
observation_sections: list[ObservationSection] | None = Field(
default=None, description="Observation sections for done action (when output_mode=observations)"
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
observations: list[MentalModelObservation] | None = Field(
default=None, description="Observations for done action (when output_mode=observations)"
)
# Plain text answer fields (for output_mode=answer)
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
@@ -50,8 +73,7 @@ class ReflectActionBatch(BaseModel):
class ToolCall(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, expand")
reason: str | None = Field(default=None, description="Agent's reasoning for making this tool call")
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
@@ -63,47 +85,30 @@ class LLMCall(BaseModel):
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: int = Field(description="Execution time in milliseconds")
input_tokens: int = Field(default=0, description="Input tokens used")
output_tokens: int = Field(default=0, description="Output tokens used")
class DirectiveInfo(BaseModel):
"""Information about a directive that was applied during reflect."""
class Observation(BaseModel):
"""A single observation with supporting memories."""
id: str = Field(description="Directive mental model ID")
name: str = Field(description="Directive name")
content: str = Field(description="Directive content")
class TokenUsageSummary(BaseModel):
"""Total token usage across all LLM calls."""
input_tokens: int = Field(default=0, description="Total input tokens used")
output_tokens: int = Field(default=0, description="Total output tokens used")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
title: str = Field(description="Observation title/header")
text: str = Field(description="Observation content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
class ReflectAgentResult(BaseModel):
"""Result from the reflect agent."""
text: str = Field(description="Final answer text")
observations: list[Observation] = Field(
default_factory=list, description="Structured observations (when output_mode=observations)"
)
structured_output: dict[str, Any] | None = Field(
default=None, description="Structured output parsed according to provided response_schema"
)
iterations: int = Field(default=0, description="Number of iterations taken")
tools_called: int = Field(default=0, description="Total number of tool calls made")
mental_models_created: list[str] = Field(default_factory=list, description="IDs of mental models created/updated")
tool_trace: list[ToolCall] = Field(default_factory=list, description="Trace of all tool calls made")
llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made")
usage: TokenUsageSummary = Field(
default_factory=TokenUsageSummary, description="Total token usage across all LLM calls"
)
used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
used_mental_model_ids: list[str] = Field(
default_factory=list, description="Validated mental model IDs actually used in answer"
)
used_observation_ids: list[str] = Field(
default_factory=list, description="Validated observation IDs actually used in answer"
)
directives_applied: list[DirectiveInfo] = Field(
default_factory=list, description="Directive mental models that affected this reflection"
)
used_model_ids: list[str] = Field(default_factory=list, description="Validated model IDs actually used in answer")
@@ -1,186 +0,0 @@
"""
Models and utilities for evidence-grounded observations with computed trends.
Observations are part of mental models and represent patterns/beliefs derived
from memories. Each observation must be grounded in specific evidence (quotes)
from memories, and trends are computed algorithmically from evidence timestamps.
"""
from datetime import datetime, timedelta, timezone
from enum import Enum
from pydantic import BaseModel, Field, computed_field, field_validator
class Trend(str, Enum):
"""Computed trend for an observation based on evidence timestamps.
Trends indicate how an observation's evidence is distributed over time:
- STABLE: Evidence spread across time, continues to present
- STRENGTHENING: More/denser evidence recently than before
- WEAKENING: Evidence mostly old, sparse recently
- NEW: All evidence within recent window
- STALE: No evidence in recent window (may no longer apply)
"""
STABLE = "stable"
STRENGTHENING = "strengthening"
WEAKENING = "weakening"
NEW = "new"
STALE = "stale"
class ObservationEvidence(BaseModel):
"""A single piece of evidence supporting an observation.
Each evidence item must include an exact quote from the source memory
to ensure observations are grounded and verifiable.
"""
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
quote: str = Field(description="Exact quote from the memory supporting the observation")
relevance: str = Field(default="", description="Brief explanation of how this quote supports the observation")
timestamp: datetime = Field(description="When the source memory was created")
@field_validator("timestamp", mode="before")
@classmethod
def ensure_timezone_aware(cls, v: datetime | str | None) -> datetime:
"""Ensure timestamp is always timezone-aware UTC."""
if v is None:
return datetime.now(timezone.utc)
if isinstance(v, str):
# Parse ISO format string, handling 'Z' suffix
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if isinstance(v, datetime):
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
raise ValueError(f"Invalid timestamp type: {type(v)}")
class Observation(BaseModel):
"""A single observation within a mental model.
Observations represent patterns, preferences, beliefs, or other insights
derived from memories. Each observation must be grounded in evidence
with exact quotes from source memories.
"""
title: str = Field(description="Short summary title for the observation (5-10 words)")
content: str = Field(description="The observation content - detailed explanation of what we believe to be true")
evidence: list[ObservationEvidence] = Field(default_factory=list, description="Supporting evidence with quotes")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this observation was first created"
)
@field_validator("created_at", mode="before")
@classmethod
def ensure_created_at_timezone_aware(cls, v: datetime | str | None) -> datetime:
"""Ensure created_at is always timezone-aware UTC."""
if v is None:
return datetime.now(timezone.utc)
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if isinstance(v, datetime):
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
raise ValueError(f"Invalid created_at type: {type(v)}")
@computed_field
@property
def trend(self) -> Trend:
"""Compute trend from evidence timestamps."""
return compute_trend(self.evidence)
@computed_field
@property
def evidence_span(self) -> dict[str, str | None]:
"""Get the time span covered by evidence."""
if not self.evidence:
return {"from": None, "to": None}
timestamps = [e.timestamp for e in self.evidence]
return {
"from": min(timestamps).isoformat(),
"to": max(timestamps).isoformat(),
}
@computed_field
@property
def evidence_count(self) -> int:
"""Number of evidence items supporting this observation."""
return len(self.evidence)
def compute_trend(
evidence: list[ObservationEvidence],
now: datetime | None = None,
recent_days: int = 30,
old_days: int = 90,
) -> Trend:
"""Compute the trend for an observation based on evidence timestamps.
The trend indicates how the evidence is distributed over time:
- STABLE: Evidence spread across time, continues to present
- STRENGTHENING: More evidence recently than historically
- WEAKENING: Evidence mostly old, sparse recently
- NEW: All evidence is recent (within recent_days)
- STALE: No evidence in recent window
Args:
evidence: List of evidence items with timestamps
now: Reference time for calculations (defaults to current UTC time)
recent_days: Number of days to consider "recent" (default 30)
old_days: Number of days to consider "old" (default 90)
Returns:
Computed Trend enum value
"""
if now is None:
now = datetime.now(timezone.utc)
# Ensure now is timezone-aware
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
if not evidence:
return Trend.STALE
recent_cutoff = now - timedelta(days=recent_days)
old_cutoff = now - timedelta(days=old_days)
# Normalize timestamps to UTC for comparison
def normalize_ts(ts: datetime) -> datetime:
if ts.tzinfo is None:
return ts.replace(tzinfo=timezone.utc)
return ts
recent = [e for e in evidence if normalize_ts(e.timestamp) > recent_cutoff]
old = [e for e in evidence if normalize_ts(e.timestamp) < old_cutoff]
middle = [e for e in evidence if old_cutoff <= normalize_ts(e.timestamp) <= recent_cutoff]
# No recent evidence = stale
if not recent:
return Trend.STALE
# All evidence is recent = new
if not old and not middle:
return Trend.NEW
# Compare density (evidence per day)
recent_density = len(recent) / recent_days if recent_days > 0 else 0
older_period = old_days - recent_days
older_density = (len(old) + len(middle)) / older_period if older_period > 0 else 0
# Avoid division by zero
if older_density == 0:
return Trend.NEW
ratio = recent_density / older_density
if ratio > 1.5:
return Trend.STRENGTHENING
elif ratio < 0.5:
return Trend.WEAKENING
else:
return Trend.STABLE
@@ -1,313 +1,147 @@
"""
System prompts for the reflect agent.
The reflect agent uses hierarchical retrieval:
1. search_mental_models - User-curated summaries (highest quality)
2. search_observations - Consolidated knowledge with freshness awareness
3. recall - Raw facts as ground truth fallback
"""
import json
from typing import Any
def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
"""
Extract directive rules as a list of strings.
Args:
directives: List of directives with name and content
Returns:
List of directive rule strings
"""
rules = []
for directive in directives:
directive_name = directive.get("name", "")
# New format: directives have direct content field
content = directive.get("content", "")
if content:
if directive_name:
rules.append(f"**{directive_name}**: {content}")
else:
rules.append(content)
else:
# Legacy format: check for observations
observations = directive.get("observations", [])
if observations:
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "title"):
title = obs.title
obs_content = obs.content
else:
title = obs.get("title", "")
obs_content = obs.get("content", "")
if title and obs_content:
rules.append(f"**{title}**: {obs_content}")
elif obs_content:
rules.append(obs_content)
elif directive_name:
# Fallback to description
desc = directive.get("description", "")
if desc:
rules.append(f"**{directive_name}**: {desc}")
return rules
def build_directives_section(directives: list[dict[str, Any]]) -> str:
"""
Build the directives section for the system prompt.
Directives are hard rules that MUST be followed in all responses.
Args:
directives: List of directive mental models with observations
"""
if not directives:
return ""
rules = _extract_directive_rules(directives)
if not rules:
return ""
parts = [
"## DIRECTIVES (MANDATORY)",
"These are hard rules you MUST follow in ALL responses:",
"",
]
for rule in rules:
parts.append(f"- {rule}")
parts.extend(
[
"",
"NEVER violate these directives, even if other context suggests otherwise.",
"IMPORTANT: Do NOT explain or justify how you handled directives in your answer. Just follow them silently.",
"",
]
)
return "\n".join(parts)
def build_directives_reminder(directives: list[dict[str, Any]]) -> str:
"""
Build a reminder section for directives to place at the end of the prompt.
Args:
directives: List of directive mental models with observations
"""
if not directives:
return ""
rules = _extract_directive_rules(directives)
if not rules:
return ""
parts = [
"",
"## REMINDER: MANDATORY DIRECTIVES",
"Before responding, ensure your answer complies with ALL of these directives:",
"",
]
for i, rule in enumerate(rules, 1):
parts.append(f"{i}. {rule}")
parts.append("")
parts.append("Your response will be REJECTED if it violates any directive above.")
parts.append("Do NOT include any commentary about how you handled directives - just follow them.")
return "\n".join(parts)
def build_system_prompt_for_tools(
bank_profile: dict[str, Any],
context: str | None = None,
directives: list[dict[str, Any]] | None = None,
has_mental_models: bool = False,
budget: str | None = None,
output_mode: str = "answer",
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
The agent uses hierarchical retrieval:
1. search_mental_models - User-curated summaries (try first, if available)
2. search_observations - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
This is a simplified prompt since tools are defined separately via the tools parameter.
Args:
bank_profile: Bank profile with name and mission
context: Optional additional context
directives: Optional list of directive mental models to inject as hard rules
has_mental_models: Whether the bank has any mental models (skip if not)
budget: Search depth budget - "low", "mid", or "high". Controls exploration thoroughness.
output_mode: "answer" for plain text response, "observations" for structured observations
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts = []
# Build critical rules based on mode
if output_mode == "observations":
no_info_rule = "- Only say 'I don't have information' AFTER trying recall with no relevant results"
else:
no_info_rule = (
"- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
)
# Inject directives at the VERY START for maximum prominence
if directives:
parts.append(build_directives_section(directives))
parts = [
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
"",
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST call recall() before saying you don't have information",
no_info_rule,
"",
"## How to Reason",
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
"- Synthesize a coherent narrative from related memories",
"- Be a thoughtful interpreter, not just a literal repeater",
"- When the exact answer isn't stated, use what IS stated to give the best answer",
"",
"## Query Strategy (IMPORTANT)",
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
"",
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
"GOOD: Break it down into component searches:",
" 1. recall('lessons') - find all lesson-related memories",
" 2. recall('teaching sessions') - alternative phrasing",
" 3. recall('student progress') - find student-related memories",
" 4. recall('topics taught') - find subject matter",
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
"- Questions about patterns → search for the individual instances first",
"- Questions comparing things → search for each thing separately",
"- Questions about relationships → search for each party involved",
"",
"## Workflow",
]
parts.extend(
[
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
"",
]
)
parts.extend(
[
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST search before saying you don't have information",
"",
"## How to Reason",
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
"- Synthesize a coherent narrative from related memories",
"- Be a thoughtful interpreter, not just a literal repeater",
"- When the exact answer isn't stated, use what IS stated to give the best answer",
"",
"## HIERARCHICAL RETRIEVAL STRATEGY",
"",
]
)
# Build retrieval levels based on what's available
if has_mental_models:
# Mode-specific workflow and output format
if output_mode == "observations":
# Observations mode: for mental model generation - no mental model lookup tools
parts.extend(
[
"You have access to THREE levels of knowledge. Use them in this order:",
"1. DECOMPOSE the topic into component searches (see Query Strategy above)",
" - Don't search for the topic name itself - search for related concepts",
" - Example for 'Coffee preferences': search 'coffee', 'drinks', 'morning routine', 'caffeine'",
"2. Run multiple recall() calls with varied, targeted queries",
"3. IMPORTANT: Use expand(memory_ids, 'chunk') to verify memories before using them",
" - Always verify the source chunk to confirm the memory is actually relevant",
" - Don't assume a memory is relevant based on the summary alone",
" - Only include memories you've verified via expand()",
"4. When ready, call done() with MULTIPLE structured observations",
"",
"### 1. MENTAL MODELS (search_mental_models) - Try First",
"- User-curated summaries about specific topics",
"- HIGHEST quality - manually created and maintained",
"- If a relevant mental model exists and is FRESH, it may fully answer the question",
"- Check `is_stale` field - if stale, also verify with lower levels",
"## Output Format: MULTIPLE Structured Observations",
"",
"### 2. OBSERVATIONS (search_observations) - Second Priority",
"- Auto-consolidated knowledge from memories",
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
"- Good for understanding patterns and summaries",
"CRITICAL: You MUST create MULTIPLE separate observations in the array - one for each theme.",
"Do NOT put all content in a single observation!",
"",
"### 3. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no mental models/observations exist, they're stale, or you need specific details",
"- This is the source of truth that other levels are built from",
"- Create 3-8 separate observations, each as its OWN item in the observations array",
"- Each observation covers ONE specific theme (preferences, history, relationships, etc.)",
"- Each observation has: title (short header), text (content), memory_ids (full UUIDs)",
"",
"Text format for each observation:",
"- Main insight or finding (no markdown headers)",
"- End with 'Key evidence:' section containing DIRECT QUOTES from memories in *italics*",
"- Quote the actual memory text, don't summarize - use *italics* for citations",
"",
"Example done() call with MULTIPLE observations:",
"```json",
"{",
' "observations": [',
" {",
' "title": "Work Preferences",',
' "text": "Prefers async communication and flexible schedules.\\n\\nKey evidence:\\n- *I prefer Slack over calls for most communication*\\n- *Flexible hours help me do my best work*",',
' "memory_ids": ["abc123-full-uuid", "def456-full-uuid"]',
" },",
" {",
' "title": "Technical Background",',
' "text": "Has extensive ML experience spanning a decade.\\n\\nKey evidence:\\n- *I have 10 years of experience in machine learning*\\n- *Led the ML team at my previous company*",',
' "memory_ids": ["ghi789-full-uuid"]',
" }",
" ]",
"}",
"```",
]
)
else:
# Answer mode: include mental model lookup in workflow
parts.extend(
[
"You have access to TWO levels of knowledge. Use them in this order:",
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
"2. If relevant, call get_mental_model(model_id) for full observations",
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
" - Identify entities and concepts in the question",
" - Search for each separately with targeted queries",
"4. Run multiple recall() calls - don't just echo the user's question",
"5. Use expand() if you need more context on specific memories",
"6. If you discover an important recurring topic worth tracking, use learn() to create a mental model",
"7. When ready, call done() with your answer and supporting memory_ids",
"",
"### 1. OBSERVATIONS (search_observations) - Try First",
"- Auto-consolidated knowledge from memories",
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
"- Good for understanding patterns and summaries",
"",
"### 2. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no observations exist, they're stale, or you need specific details",
"- This is the source of truth that observations are built from",
"## When to Use learn()",
"Use learn() to create a new mental model when you discover:",
"- A person, project, or concept that appears frequently in memories",
"- An important topic the user seems to care about but has no mental model for",
"- A pattern or relationship worth synthesizing for future reference",
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put memory IDs ONLY in the memory_ids array parameter, not in the answer",
]
)
parts.extend(
[
"## Query Strategy",
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
"",
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
"GOOD: Break it down into component searches:",
" 1. recall('lessons') - find all lesson-related memories",
" 2. recall('teaching sessions') - alternative phrasing",
" 3. recall('student progress') - find student-related memories",
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
"",
]
)
# Add budget guidance
if budget:
budget_lower = budget.lower()
if budget_lower == "low":
parts.extend(
[
"## RESEARCH DEPTH: SHALLOW (Quick Response)",
"- Prioritize speed over completeness",
"- If mental models or observations provide a reasonable answer, stop there",
"- Only dig deeper if the initial results are clearly insufficient",
"- Prefer a quick overview rather than exhaustive details",
"- Answer promptly with available information",
"",
]
)
elif budget_lower == "mid":
parts.extend(
[
"## RESEARCH DEPTH: MODERATE (Balanced)",
"- Balance thoroughness with efficiency",
"- Check multiple sources when the question warrants it",
"- Verify stale data if it's central to the answer",
"- Don't over-explore, but ensure reasonable coverage",
"",
]
)
elif budget_lower == "high":
parts.extend(
[
"## RESEARCH DEPTH: DEEP (Thorough Exploration)",
"- Explore comprehensively before answering",
"- Search across all available knowledge levels",
"- Use multiple query variations to ensure coverage",
"- Verify information across different retrieval levels",
"- Use expand() to get full context on important memories",
"- Take time to synthesize a complete, well-researched answer",
"",
]
)
parts.append("## Workflow")
if has_mental_models:
parts.extend(
[
"1. First, try search_mental_models() - check if a curated summary exists",
"2. If no mental model or it's stale, try search_observations() for consolidated knowledge",
"3. If observations are stale OR you need specific details, use recall() for raw facts",
"4. Use expand() if you need more context on specific memories",
"5. When ready, call done() with your answer and supporting IDs",
]
)
else:
parts.extend(
[
"1. First, try search_observations() - check for consolidated knowledge",
"2. If observations are stale OR you need specific details, use recall() for raw facts",
"3. Use expand() if you need more context on specific memories",
"4. When ready, call done() with your answer and supporting IDs",
]
)
parts.extend(
[
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put IDs ONLY in the memory_ids/mental_model_ids/observation_ids arrays, not in the answer",
]
)
parts.append("")
parts.append(f"## Memory Bank: {name}")
@@ -330,10 +164,6 @@ def build_system_prompt_for_tools(
if context:
parts.append(f"\n## Additional Context\n{context}")
# Add directive reminder at the END for recency effect
if directives:
parts.append(build_directives_reminder(directives))
return "\n".join(parts)
@@ -398,10 +228,9 @@ def build_agent_prompt(
else:
parts.append(
"\n## Instructions\n"
"Start by searching for relevant information using the hierarchical retrieval strategy:\n"
"1. Try search_mental_models() first for curated summaries\n"
"2. Try search_observations() for consolidated knowledge\n"
"3. Use recall() for specific details or to verify stale data"
"Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. "
"If a relevant model exists, use get_mental_model(model_id) to get its observations. "
"Then use recall(query) for specific details not covered by mental models."
)
return "\n".join(parts)
@@ -1,17 +1,14 @@
"""
Tool implementations for the reflect agent.
Implements hierarchical retrieval:
1. search_mental_models - User-curated stored reflect responses (highest quality)
2. search_observations - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
"""
import logging
import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from .models import MentalModelInput
if TYPE_CHECKING:
from asyncpg import Connection
@@ -20,215 +17,133 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Observation is considered stale if not updated in this many days
STALE_THRESHOLD_DAYS = 7
def generate_model_id(name: str) -> str:
"""Generate a stable ID from mental model name."""
# Normalize: lowercase, replace spaces/special chars with hyphens
normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# Truncate to reasonable length
return normalized[:50]
async def tool_search_mental_models(
async def tool_lookup(
conn: "Connection",
bank_id: str,
query: str,
query_embedding: list[float],
max_results: int = 5,
model_id: str | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
exclude_ids: list[str] | None = None,
) -> dict[str, Any]:
"""
Search user-curated mental models by semantic similarity.
Mental models are high-quality, manually created summaries about specific topics.
They should be searched FIRST as they represent the most reliable synthesized knowledge.
List or get mental models.
Args:
conn: Database connection
bank_id: Bank identifier
query: Search query (for logging/tracing)
query_embedding: Pre-computed embedding for semantic search
max_results: Maximum number of mental models to return
tags: Optional tags to filter mental models
model_id: Optional specific model ID to get (if None, lists all)
tags: Optional tags to filter models (when listing)
tags_match: How to match tags - "any" (OR), "all" (AND)
exclude_ids: Optional list of mental model IDs to exclude (e.g., when refreshing a mental model)
Returns:
Dict with matching mental models including content and freshness info
Dict with either a list of models or a single model's details
"""
from ..memory_engine import fq_table
# Build filters dynamically
filters = ""
params: list[Any] = [bank_id, str(query_embedding), max_results]
next_param = 4
if tags:
if tags_match == "all":
filters += f" AND tags @> ${next_param}::varchar[]"
else:
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
params.append(tags)
next_param += 1
if exclude_ids:
filters += f" AND id != ALL(${next_param}::text[])"
params.append(exclude_ids)
next_param += 1
# Search mental models by embedding similarity
rows = await conn.fetch(
f"""
SELECT
id, name, content,
tags, created_at, last_refreshed_at,
1 - (embedding <=> $2::vector) as relevance
FROM {fq_table("mental_models")}
WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
ORDER BY embedding <=> $2::vector
LIMIT $3
""",
*params,
)
now = datetime.now(timezone.utc)
mental_models = []
for row in rows:
last_refreshed_at = row["last_refreshed_at"]
if last_refreshed_at and last_refreshed_at.tzinfo is None:
last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc)
# Calculate freshness
is_stale = False
if last_refreshed_at:
age = now - last_refreshed_at
is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
mental_models.append(
{
"id": str(row["id"]),
"name": row["name"],
"content": row["content"],
"tags": row["tags"] or [],
"relevance": round(row["relevance"], 4),
"updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None,
"is_stale": is_stale,
}
if model_id:
# Get specific mental model with full details including observations
row = await conn.fetchrow(
"""
SELECT id, subtype, name, description, observations, entity_id, last_updated
FROM mental_models
WHERE id = $1 AND bank_id = $2
""",
model_id,
bank_id,
)
if row:
# Parse observations JSON
obs_data = row["observations"] or {"observations": []}
if isinstance(obs_data, str):
import json
return {
"query": query,
"count": len(mental_models),
"mental_models": mental_models,
}
obs_data = json.loads(obs_data)
observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data
# Normalize observation format: map memory_ids/fact_ids to based_on
observations = []
for obs in observations_raw:
if isinstance(obs, dict):
based_on = obs.get("memory_ids") or obs.get("fact_ids") or []
observations.append(
{
"title": obs.get("title", ""),
"text": obs.get("text", ""),
"based_on": based_on,
}
)
async def tool_search_observations(
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
max_tokens: int = 5000,
tags: list[str] | None = None,
tags_match: str = "any",
last_consolidated_at: datetime | None = None,
pending_consolidation: int = 0,
) -> dict[str, Any]:
"""
Search consolidated observations using recall with include_observations.
Observations are auto-generated from memories. Returns freshness info
so the agent knows if it should also verify with recall().
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
query: Search query
request_context: Request context for authentication
max_tokens: Maximum tokens for results (default 5000)
tags: Optional tags to filter observations
tags_match: How to match tags - "any" (OR), "all" (AND)
last_consolidated_at: When consolidation last ran (for staleness check)
pending_consolidation: Number of memories waiting to be consolidated
Returns:
Dict with matching observations including freshness info
"""
from ..memory_engine import fq_table
# Use recall to search observations (they come back in results field when fact_type=["observation"])
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["observation"], # Only retrieve observations
max_tokens=max_tokens, # Token budget controls how many observations are returned
enable_trace=False,
request_context=request_context,
tags=tags,
tags_match=tags_match,
_connection_budget=1,
_quiet=True,
)
observations = []
# When fact_type=["observation"], results come back in `results` field as MemoryFact objects
# We need to fetch additional fields (proof_count, source_memory_ids) from the database
if result.results:
obs_ids = [m.id for m in result.results]
# Fetch proof_count and source_memory_ids for these observations
pool = await memory_engine._get_pool()
async with pool.acquire() as conn:
obs_rows = await conn.fetch(
f"""
SELECT id, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
obs_ids,
)
obs_data = {str(row["id"]): row for row in obs_rows}
for m in result.results:
# Get additional data from DB lookup
extra = obs_data.get(m.id, {})
proof_count = extra.get("proof_count", 1) if extra else 1
source_ids = extra.get("source_memory_ids", []) if extra else []
# Convert UUIDs to strings
source_memory_ids = [str(sid) for sid in (source_ids or [])]
# Determine staleness
is_stale = False
staleness_reason = None
if pending_consolidation > 0:
is_stale = True
staleness_reason = f"{pending_consolidation} memories pending consolidation"
observations.append(
{
"id": str(m.id),
"text": m.text,
"proof_count": proof_count,
"source_memory_ids": source_memory_ids,
"tags": m.tags or [],
"is_stale": is_stale,
"staleness_reason": staleness_reason,
}
)
# Return freshness info (more understandable than raw pending_consolidation count)
if pending_consolidation == 0:
freshness = "up_to_date"
elif pending_consolidation < 10:
freshness = "slightly_stale"
return {
"found": True,
"model": {
"id": row["id"],
"subtype": row["subtype"],
"name": row["name"],
"description": row["description"],
"observations": observations, # [{title, text, based_on}, ...]
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
},
}
return {"found": False, "model_id": model_id}
else:
freshness = "stale"
# List mental models (compact: id, name, description only)
# Full observations are retrieved via get_mental_model(model_id)
# Filter by tags if provided
if tags:
if tags_match == "all":
# All tags must match
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND tags @> $2::varchar[]
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
tags,
)
else:
# Any tag matches (OR) - default
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND tags && $2::varchar[]
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
tags,
)
else:
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
)
return {
"query": query,
"count": len(observations),
"observations": observations,
"freshness": freshness,
}
return {
"count": len(rows),
"models": [
{
"id": row["id"],
"subtype": row["subtype"],
"name": row["name"],
"description": row["description"],
}
for row in rows
],
}
async def tool_recall(
@@ -245,9 +160,6 @@ async def tool_recall(
"""
Search memories using TEMPR retrieval.
This is the ground truth - raw facts and experiences.
Use when mental models/observations don't exist, are stale, or need verification.
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
@@ -265,14 +177,13 @@ async def tool_recall(
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"], # Exclude opinions and observations
fact_type=["experience", "world"], # Exclude opinions
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
tags=tags,
tags_match=tags_match,
_connection_budget=connection_budget,
_quiet=True, # Suppress logging for internal operations
)
memories = []
@@ -294,6 +205,85 @@ async def tool_recall(
}
async def tool_learn(
conn: "Connection",
bank_id: str,
input: MentalModelInput,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Create a mental model placeholder with subtype='learned'.
The agent only specifies name and description - actual observations are generated
in the background via refresh, similar to pinned models.
Args:
conn: Database connection
bank_id: Bank identifier
input: Mental model input data (name, description, optional entity_id)
tags: Tags to apply to new mental models (from reflect context)
Returns:
Dict with created model info including model_id for background generation
"""
model_id = generate_model_id(input.name)
# Parse entity_id if provided
entity_uuid = None
if input.entity_id:
try:
entity_uuid = uuid.UUID(input.entity_id)
except ValueError:
logger.warning(f"Invalid entity_id format: {input.entity_id}")
# Check if model exists
existing = await conn.fetchrow(
"SELECT id FROM mental_models WHERE id = $1 AND bank_id = $2",
model_id,
bank_id,
)
if existing:
# Update description only - observations will be regenerated
await conn.execute(
"""
UPDATE mental_models SET
description = $3,
entity_id = $4
WHERE id = $1 AND bank_id = $2
""",
model_id,
bank_id,
input.description,
entity_uuid,
)
status = "updated"
else:
# Insert new model placeholder - observations will be generated in background
await conn.execute(
"""
INSERT INTO mental_models (id, bank_id, subtype, name, description, observations, entity_id, tags, created_at)
VALUES ($1, $2, 'learned', $3, $4, '{}'::jsonb, $5, $6, NOW())
""",
model_id,
bank_id,
input.name,
input.description,
entity_uuid,
tags or [],
)
status = "created"
logger.info(f"[REFLECT] Mental model '{model_id}' {status} in bank {bank_id} - pending background generation")
return {
"status": status,
"model_id": model_id,
"name": input.name,
"pending_generation": True,
}
async def tool_expand(
conn: "Connection",
bank_id: str,
@@ -312,8 +302,6 @@ async def tool_expand(
Returns:
Dict with results array, each containing memory, chunk, and optionally document data
"""
from ..memory_engine import fq_table
if not memory_ids:
return {"error": "memory_ids is required and must not be empty"}
@@ -331,9 +319,9 @@ async def tool_expand(
# Batch fetch all memory units
memories = await conn.fetch(
f"""
"""
SELECT id, text, chunk_id, document_id, fact_type, context
FROM {fq_table("memory_units")}
FROM memory_units
WHERE id = ANY($1) AND bank_id = $2
""",
valid_uuids,
@@ -350,9 +338,9 @@ async def tool_expand(
chunk_map: dict[str, Any] = {}
if chunk_ids:
chunks = await conn.fetch(
f"""
"""
SELECT chunk_id, chunk_text, chunk_index, document_id
FROM {fq_table("chunks")}
FROM chunks
WHERE chunk_id = ANY($1)
""",
chunk_ids,
@@ -372,9 +360,9 @@ async def tool_expand(
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
if all_doc_ids:
docs = await conn.fetch(
f"""
"""
SELECT id, original_text, metadata, retain_params
FROM {fq_table("documents")}
FROM documents
WHERE id = ANY($1) AND bank_id = $2
""",
all_doc_ids,
@@ -2,70 +2,38 @@
Tool schema definitions for the reflect agent.
These are OpenAI-format tool definitions used with native tool calling.
The reflect agent uses a hierarchical retrieval strategy:
1. search_mental_models - User-curated stored reflect responses (highest quality, if applicable)
2. search_observations - Consolidated knowledge with freshness awareness
3. recall - Raw facts (world/experience) as ground truth fallback
"""
# Tool definitions in OpenAI format
from typing import Literal
TOOL_SEARCH_MENTAL_MODELS = {
# Tool definitions in OpenAI format
TOOL_LIST_MENTAL_MODELS = {
"type": "function",
"function": {
"name": "search_mental_models",
"description": (
"Search user-curated mental models (stored reflect responses). These are high-quality, manually created "
"summaries about specific topics. Use FIRST when the question might be covered by an "
"existing mental model. Returns mental models with their content and last refresh time."
),
"name": "list_mental_models",
"description": "List all available mental models - your synthesized knowledge about entities, concepts, and events. Returns an array of models with id, name, and description.",
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you're making this search (for debugging)",
},
"query": {
"type": "string",
"description": "Search query to find relevant mental models",
},
"max_results": {
"type": "integer",
"description": "Maximum number of mental models to return (default 5)",
},
},
"required": ["reason", "query"],
"properties": {},
"required": [],
},
},
}
TOOL_SEARCH_OBSERVATIONS = {
TOOL_GET_MENTAL_MODEL = {
"type": "function",
"function": {
"name": "search_observations",
"description": (
"Search consolidated observations (auto-generated knowledge). These are automatically "
"synthesized from memories. Returns observations with freshness info (updated_at, is_stale). "
"If an observation is STALE, you should ALSO use recall() to verify with current facts."
),
"name": "get_mental_model",
"description": "Get full details of a specific mental model including all observations and memory references.",
"parameters": {
"type": "object",
"properties": {
"reason": {
"model_id": {
"type": "string",
"description": "Brief explanation of why you're making this search (for debugging)",
},
"query": {
"type": "string",
"description": "Search query to find relevant observations",
},
"max_tokens": {
"type": "integer",
"description": "Maximum tokens for results (default 5000). Use higher values for broader searches.",
"description": "ID of the mental model (from list_mental_models results)",
},
},
"required": ["reason", "query"],
"required": ["model_id"],
},
},
}
@@ -74,19 +42,10 @@ TOOL_RECALL = {
"type": "function",
"function": {
"name": "recall",
"description": (
"Search raw memories (facts and experiences). This is the ground truth data. "
"Use when: (1) no reflections/mental models exist, (2) mental models are stale, "
"(3) you need specific details not in synthesized knowledge. "
"Returns individual memory facts with their timestamps."
),
"description": "Search memories using semantic + temporal retrieval. Returns relevant memories from experience and world knowledge, each with an 'id' you can reference.",
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you're making this search (for debugging)",
},
"query": {
"type": "string",
"description": "Search query string",
@@ -96,7 +55,29 @@ TOOL_RECALL = {
"description": "Optional limit on result size (default 2048). Use higher values for broader searches.",
},
},
"required": ["reason", "query"],
"required": ["query"],
},
},
}
TOOL_LEARN = {
"type": "function",
"function": {
"name": "learn",
"description": "Create a new mental model to track an important recurring topic. Use when you discover a person, project, concept, or pattern that appears frequently and would benefit from synthesized knowledge. The model content will be generated automatically.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable name (e.g., 'Project Alpha', 'John Smith', 'Product Strategy')",
},
"description": {
"type": "string",
"description": "What to track and synthesize (e.g., 'Track goals, milestones, blockers, and key decisions for Project Alpha')",
},
},
"required": ["name", "description"],
},
},
}
@@ -109,10 +90,6 @@ TOOL_EXPAND = {
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you need more context (for debugging)",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
@@ -124,7 +101,7 @@ TOOL_EXPAND = {
"description": "chunk: surrounding text chunk, document: full source document",
},
},
"required": ["reason", "memory_ids", "depth"],
"required": ["memory_ids", "depth"],
},
},
}
@@ -146,104 +123,89 @@ TOOL_DONE_ANSWER = {
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"mental_model_ids": {
"model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
},
"observation_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of observation IDs that support your answer",
},
},
"required": ["answer"],
},
},
}
def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
"""
Build the done tool schema with directive compliance field.
When directives are present, adds a required field that forces the agent
to confirm compliance with each directive before submitting.
Args:
directive_rules: List of directive rule strings
"""
# Build rules list for description
rules_list = "\n".join(f" {i + 1}. {rule}" for i, rule in enumerate(directive_rules))
# Build the tool with directive compliance field
return {
"type": "function",
"function": {
"name": "done",
"description": (
"Signal completion with your final answer. IMPORTANT: You must confirm directive compliance before submitting. "
"Your answer will be REJECTED if it violates any directive."
),
"parameters": {
"type": "object",
"properties": {
"answer": {
"type": "string",
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
},
"observation_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of observation IDs that support your answer",
},
"directive_compliance": {
"type": "string",
"description": f"REQUIRED: Confirm your answer complies with ALL directives. List each directive and how your answer follows it:\n{rules_list}\n\nFormat: 'Directive 1: [how answer complies]. Directive 2: [how answer complies]...'",
TOOL_DONE_OBSERVATIONS = {
"type": "function",
"function": {
"name": "done",
"description": "Signal completion with MULTIPLE structured observations. Each observation must be a SEPARATE item in the array covering ONE theme. Do NOT combine all content into a single observation.",
"parameters": {
"type": "object",
"properties": {
"observations": {
"type": "array",
"minItems": 3,
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short header for this observation's theme (e.g., 'Work Style', 'Technical Skills')",
},
"text": {
"type": "string",
"description": "Observation content about ONE theme. End with 'Key evidence:' containing text citations (summaries of what memories say), NOT memory IDs.",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Full UUIDs of memories supporting this observation (put IDs here, not in text)",
},
},
"required": ["title", "text", "memory_ids"],
},
"description": "Array of 3-8 observations, each covering a DIFFERENT aspect/theme. Do NOT put everything in one observation.",
},
"required": ["answer", "directive_compliance"],
},
"required": ["observations"],
},
}
},
}
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
def get_reflect_tools(
enable_learn: bool = True, output_mode: Literal["answer", "observations"] = "answer"
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
The tools support a hierarchical retrieval strategy:
1. search_mental_models - User-curated stored reflect responses (try first)
2. search_observations - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
directive_rules: Optional list of directive rule strings. If provided,
the done() tool will require directive compliance confirmation.
enable_learn: Whether to include the learn tool
output_mode: "answer" or "observations" - determines done tool format
In observations mode, mental model tools are excluded to avoid
using potentially outdated models during regeneration.
Returns:
List of tool definitions in OpenAI format
"""
tools = [
TOOL_SEARCH_MENTAL_MODELS,
TOOL_SEARCH_OBSERVATIONS,
TOOL_RECALL,
TOOL_EXPAND,
]
tools = []
# Use directive-aware done tool if directives are present
if directive_rules:
tools.append(_build_done_tool_with_directives(directive_rules))
# In answer mode, include mental model tools for lookup
# In observations mode (mental model generation), exclude them to avoid circular references
if output_mode == "answer":
tools.append(TOOL_LIST_MENTAL_MODELS)
tools.append(TOOL_GET_MENTAL_MODEL)
tools.append(TOOL_RECALL)
if enable_learn:
tools.append(TOOL_LEARN)
tools.append(TOOL_EXPAND)
# Add appropriate done tool based on output mode
if output_mode == "observations":
tools.append(TOOL_DONE_OBSERVATIONS)
else:
tools.append(TOOL_DONE_ANSWER)
@@ -10,8 +10,8 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience"])
class LLMToolCall(BaseModel):
@@ -28,15 +28,12 @@ class LLMToolCallResult(BaseModel):
content: str | None = Field(default=None, description="Text content if any")
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
input_tokens: int = Field(default=0, description="Input tokens used in this call")
output_tokens: int = Field(default=0, description="Output tokens used in this call")
class ToolCallTrace(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
reason: str | None = Field(default=None, description="Agent's reasoning for making this tool call")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
@@ -50,25 +47,17 @@ class LLMCallTrace(BaseModel):
duration_ms: int = Field(description="Execution time in milliseconds")
class ObservationRef(BaseModel):
"""Reference to an observation accessed during reflect."""
class MentalModelRef(BaseModel):
"""Reference to a mental model accessed during reflect."""
id: str = Field(description="Observation ID")
name: str = Field(description="Observation name")
type: str = Field(description="Observation type: entity, concept, event")
subtype: str = Field(description="Observation subtype: structural, emergent, learned")
id: str = Field(description="Mental model ID")
name: str = Field(description="Mental model name")
type: str = Field(description="Mental model type: entity, concept, event")
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
description: str = Field(description="Brief description")
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
class DirectiveRef(BaseModel):
"""Reference to a directive that was applied during reflect."""
id: str = Field(description="Directive mental model ID")
name: str = Field(description="Directive name")
content: str = Field(description="Directive content")
class TokenUsage(BaseModel):
"""
Token usage metrics for LLM calls.
@@ -169,28 +158,6 @@ class ChunkInfo(BaseModel):
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
class ObservationResult(BaseModel):
"""An observation result from recall (consolidated knowledge synthesized from facts)."""
id: str = Field(description="Unique observation ID")
text: str = Field(description="The observation text")
proof_count: int = Field(description="Number of facts supporting this observation")
relevance: float = Field(default=0.0, description="Relevance score to the query")
tags: list[str] | None = Field(default=None, description="Tags for visibility scoping")
source_memory_ids: list[str] = Field(
default_factory=list, description="IDs of facts that contribute to this observation"
)
class MentalModelResult(BaseModel):
"""A mental model result from recall (stored reflect response)."""
id: str = Field(description="Unique mental model ID")
name: str = Field(description="Human-readable name")
content: str = Field(description="The synthesized content")
relevance: float = Field(default=0.0, description="Relevance score to the query")
class RecallResult(BaseModel):
"""
Result from a recall operation.
@@ -254,15 +221,8 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
"mental_models": [],
"directives": [
{
"id": "directive-123",
"name": "Response Style",
"rules": ["Always be concise"],
}
],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
"usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000},
}
@@ -270,9 +230,10 @@ class ReflectResult(BaseModel):
)
text: str = Field(description="The formulated answer text")
based_on: dict[str, Any] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, mental_models, directives)"
based_on: dict[str, list[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
)
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
structured_output: dict[str, Any] | None = Field(
default=None,
description="Structured output parsed according to the provided response schema. Only present when response_schema was provided.",
@@ -289,12 +250,30 @@ class ReflectResult(BaseModel):
default_factory=list,
description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.",
)
directives_applied: list[DirectiveRef] = Field(
mental_models: list[MentalModelRef] = Field(
default_factory=list,
description="Directive mental models that were applied during this reflection.",
description="Mental models accessed during reflection. Only present when include.facts is enabled.",
)
class Opinion(BaseModel):
"""
An opinion with confidence score.
Opinions represent the bank's formed perspectives on topics,
with a confidence level indicating strength of belief.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {"text": "Machine learning has great potential in healthcare", "confidence": 0.85}
}
)
text: str = Field(description="The opinion text")
confidence: float = Field(description="Confidence score between 0.0 and 1.0")
class EntityObservation(BaseModel):
"""
An observation about an entity.
@@ -57,25 +57,21 @@ def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
return None
def _sanitize_text(text: str | None) -> str | None:
def _sanitize_text(text: str) -> str:
"""
Sanitize text by removing characters that break downstream systems.
Sanitize text by removing invalid Unicode surrogate characters.
Removes:
- Null bytes (\\x00): Invalid in PostgreSQL UTF-8 encoding
- Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
Surrogate characters (U+D800 to U+DFFF) are used in UTF-16 encoding
but cannot be encoded in UTF-8. They can appear in Python strings
from improperly decoded data (e.g., from JavaScript or broken files).
Surrogate characters are used in UTF-16 encoding but cannot be encoded
in UTF-8. They can appear in Python strings from improperly decoded data
(e.g., from JavaScript or broken files). Null bytes commonly appear in
OCR output, PDF extraction, or copy-paste from binary sources.
This function removes unpaired surrogates to prevent UnicodeEncodeError
when the text is sent to the LLM API.
"""
if text is None:
return None
if not text:
return text
# Remove null bytes and surrogate characters
text = text.replace("\x00", "")
# Remove surrogate characters (U+D800 to U+DFFF) using regex
# These are invalid in UTF-8 and cause encoding errors
return re.sub(r"[\ud800-\udfff]", "", text)
@@ -118,8 +114,11 @@ class CausalRelation(BaseModel):
"""Causal relationship from this fact to a previous fact (stored format)."""
target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).")
relation_type: Literal["caused_by"] = Field(
description="How this fact relates to the target: 'caused_by' = this fact was caused by the target"
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
description="How this fact relates to the target: "
"'caused_by' = this fact was caused by the target, "
"'enabled_by' = this fact was enabled by the target, "
"'prevented_by' = this fact was prevented by the target"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0)",
@@ -142,8 +141,11 @@ class FactCausalRelation(BaseModel):
"MUST be less than this fact's position in the list. "
"Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
)
relation_type: Literal["caused_by"] = Field(
description="How this fact relates to the target fact: 'caused_by' = this fact was caused by the target fact"
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
description="How this fact relates to the target fact: "
"'caused_by' = this fact was caused by the target fact, "
"'enabled_by' = this fact was enabled by the target fact, "
"'prevented_by' = this fact was blocked/prevented by the target fact"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
@@ -436,15 +438,34 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
# FACT EXTRACTION PROMPTS
# =============================================================================
# Base prompt template (shared by concise and custom modes)
# Uses {extraction_guidelines} placeholder for mode-specific instructions
_BASE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
# Concise extraction prompt (default) - selective, high-quality facts
CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
LANGUAGE RULE (CRITICAL): Output facts in the EXACT SAME language as the input text. If input is Japanese, output Japanese. If input is Chinese, output Chinese. NEVER translate to English. Preserve original language completely.
{fact_types_instruction}
{extraction_guidelines}
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
ONLY extract facts that are:
Personal info: names, relationships, roles, background
Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
Significant events: milestones, decisions, achievements, changes
Plans/goals: future intentions, deadlines, commitments
Expertise: skills, knowledge, certifications, experience
Important context: projects, problems, constraints
Sensory/emotional details: feelings, sensations, perceptions that provide context
Observations: descriptions of people, places, things with specific details
DO NOT extract:
Generic greetings: "how are you", "hello", pleasantries without substance
Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
Process chatter: "let me check", "one moment", "I'll look into it"
Repeated info: if already stated, don't extract again
CONSOLIDATE related statements into ONE fact when possible.
FACT FORMAT - BE CONCISE
@@ -492,33 +513,7 @@ ENTITIES
Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
Always include "user" when fact is about the user.{examples}"""
# Concise mode guidelines
_CONCISE_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
ONLY extract facts that are:
Personal info: names, relationships, roles, background
Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
Significant events: milestones, decisions, achievements, changes
Plans/goals: future intentions, deadlines, commitments
Expertise: skills, knowledge, certifications, experience
Important context: projects, problems, constraints
Sensory/emotional details: feelings, sensations, perceptions that provide context
Observations: descriptions of people, places, things with specific details
DO NOT extract:
Generic greetings: "how are you", "hello", pleasantries without substance
Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
Process chatter: "let me check", "one moment", "I'll look into it"
Repeated info: if already stated, don't extract again
CONSOLIDATE related statements into ONE fact when possible."""
# Concise mode examples
_CONCISE_EXAMPLES = """
Always include "user" when fact is about the user.
EXAMPLES
@@ -544,20 +539,6 @@ QUALITY OVER QUANTITY
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
# Assembled concise prompt (backward compatible - exact same output as before)
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
fact_types_instruction="{fact_types_instruction}",
extraction_guidelines=_CONCISE_GUIDELINES,
examples=_CONCISE_EXAMPLES,
)
# Custom prompt uses same base but without examples
CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
fact_types_instruction="{fact_types_instruction}",
extraction_guidelines="{custom_instructions}",
examples="", # No examples for custom mode
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@@ -681,7 +662,7 @@ CAUSAL RELATIONSHIPS
Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
Type: "caused_by" (this fact was caused by the target fact)
Types: "caused_by", "enabled_by", "prevented_by"
Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 0: Lost job, causal_relations: null
@@ -697,6 +678,7 @@ async def _extract_facts_from_chunk(
context: str,
llm_config: "LLMConfig",
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
Extract facts from a single chunk (internal helper for parallel processing).
@@ -704,15 +686,17 @@ async def _extract_facts_from_chunk(
Note: event_date parameter is kept for backward compatibility but not used in prompt.
The LLM extracts temporal information from the context string instead.
"""
import logging
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
from openai import BadRequestError
logger = logging.getLogger(__name__)
# Determine which fact types to extract
# Determine which fact types to extract based on the flag
# Note: We use "assistant" in the prompt but convert to "bank" for storage
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts."
if extract_opinions:
# Opinion extraction uses a separate prompt (not this one)
fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
else:
fact_types_instruction = (
"Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
)
# Check config for extraction mode and causal link extraction
config = get_config()
@@ -720,27 +704,13 @@ async def _extract_facts_from_chunk(
extract_causal_links = config.retain_extract_causal_links
# Select base prompt based on extraction mode
if extraction_mode == "custom":
# Custom mode: inject user-provided guidelines
if not config.retain_custom_instructions:
logger.warning(
"extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
"Falling back to 'concise' mode."
)
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
base_prompt = CUSTOM_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
fact_types_instruction=fact_types_instruction,
custom_instructions=config.retain_custom_instructions,
)
elif extraction_mode == "verbose":
if extraction_mode == "verbose":
base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
# Format the prompt with fact types instruction
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
# Build the full prompt with or without causal relationships section
# Select appropriate response schema based on extraction mode and causal links
@@ -753,6 +723,12 @@ async def _extract_facts_from_chunk(
else:
response_schema = FactExtractionResponseNoCausal
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
# Retry logic for JSON validation errors
max_retries = 2
last_error = None
@@ -763,12 +739,9 @@ async def _extract_facts_from_chunk(
# Build user message with metadata and chunk content in a clear format
# Format event_date with day of week for better temporal reasoning
# Handle both datetime objects and ISO string formats (from deserialized async tasks)
from .orchestrator import parse_datetime_flexible
event_date = parse_datetime_flexible(event_date)
event_date_formatted = event_date.strftime("%A, %B %d, %Y") # e.g., "Monday, June 10, 2024"
user_message = f"""Extract facts from the following text chunk.
{memory_bank_context}
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_formatted} ({event_date.isoformat()})
@@ -780,28 +753,12 @@ Text:
usage = TokenUsage() # Track cumulative usage across retries
for attempt in range(max_retries):
try:
# Use retain-specific overrides if set, otherwise fall back to global LLM config
max_retries = (
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
)
initial_backoff = (
config.retain_llm_initial_backoff
if config.retain_llm_initial_backoff is not None
else config.llm_initial_backoff
)
max_backoff = (
config.retain_llm_max_backoff if config.retain_llm_max_backoff is not None else config.llm_max_backoff
)
extraction_response_json, call_usage = await llm_config.call(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="memory_extract_facts",
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=True, # Get raw JSON, we'll validate leniently
return_usage=True,
)
@@ -866,8 +823,7 @@ Text:
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
fact_type = llm_fact.get("fact_type")
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
@@ -884,10 +840,7 @@ Text:
else:
# Default to 'world' if we can't determine
fact_type = "world"
logger.warning(
f"Fact {i}: defaulting to fact_type='world' "
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
)
logger.warning(f"Fact {i}: defaulting to fact_type='world'")
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -1027,6 +980,7 @@ async def _extract_facts_with_auto_split(
context: str,
llm_config: LLMConfig,
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
Extract facts from a chunk with automatic splitting if output exceeds token limits.
@@ -1042,6 +996,7 @@ async def _extract_facts_with_auto_split(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name (memory owner)
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
Tuple of (facts list, token usage) extracted from the chunk (possibly from sub-chunks)
@@ -1060,6 +1015,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
except OutputTooLongError:
# Output exceeded token limits - split the chunk in half and retry
@@ -1104,6 +1060,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
),
_extract_facts_with_auto_split(
chunk=second_half,
@@ -1113,6 +1070,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
),
]
@@ -1136,6 +1094,7 @@ async def extract_facts_from_text(
llm_config: LLMConfig,
agent_name: str,
context: str = "",
extract_opinions: bool = False,
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
"""
Extract semantic facts from conversational or narrative text using LLM.
@@ -1152,6 +1111,7 @@ async def extract_facts_from_text(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Agent name (memory owner)
extract_opinions: If True, extract ONLY opinions. If False, extract world and bank facts (no opinions)
Returns:
Tuple of (facts, chunks, usage) where:
@@ -1179,6 +1139,7 @@ async def extract_facts_from_text(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
for i, chunk in enumerate(chunks)
]
@@ -1210,7 +1171,7 @@ SECONDS_PER_FACT = 10
async def extract_facts_from_contents(
contents: list[RetainContent], llm_config, agent_name: str
contents: list[RetainContent], llm_config, agent_name: str, extract_opinions: bool = False
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
Extract facts from multiple content items in parallel.
@@ -1225,6 +1186,7 @@ async def extract_facts_from_contents(
contents: List of RetainContent objects to process
llm_config: LLM configuration for fact extraction
agent_name: Name of the agent (for agent-related fact detection)
extract_opinions: If True, extract only opinions; otherwise world/bank facts
Returns:
Tuple of (extracted_facts, chunks_metadata, usage)
@@ -1243,6 +1205,7 @@ async def extract_facts_from_contents(
context=item.context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
fact_extraction_tasks.append(task)
@@ -1354,8 +1317,6 @@ def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainC
Modifies facts in place.
"""
from .orchestrator import parse_datetime_flexible
# Group facts by content_index
current_content_idx = 0
content_fact_start = 0
@@ -1370,10 +1331,10 @@ def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainC
fact_position = i - content_fact_start
offset = timedelta(seconds=fact_position * SECONDS_PER_FACT)
# Apply offset to all temporal fields (handle both datetime objects and ISO strings)
# Apply offset to all temporal fields
if fact.occurred_start:
fact.occurred_start = parse_datetime_flexible(fact.occurred_start) + offset
fact.occurred_start = fact.occurred_start + offset
if fact.occurred_end:
fact.occurred_end = parse_datetime_flexible(fact.occurred_end) + offset
fact.occurred_end = fact.occurred_end + offset
if fact.mentioned_at:
fact.mentioned_at = parse_datetime_flexible(fact.mentioned_at) + offset
fact.mentioned_at = fact.mentioned_at + offset
@@ -8,7 +8,6 @@ import json
import logging
from ..memory_engine import fq_table
from .fact_extraction import _sanitize_text
from .types import ProcessedFact
logger = logging.getLogger(__name__)
@@ -42,13 +41,14 @@ async def insert_facts_batch(
contexts = []
fact_types = []
confidence_scores = []
access_counts = []
metadata_jsons = []
chunk_ids = []
document_ids = []
tags_list = []
for fact in facts:
fact_texts.append(_sanitize_text(fact.fact_text))
fact_texts.append(fact.fact_text)
# Convert embedding to string for asyncpg vector type
embeddings.append(str(fact.embedding))
# event_date: Use occurred_start if available, otherwise use mentioned_at
@@ -57,10 +57,11 @@ async def insert_facts_batch(
occurred_starts.append(fact.occurred_start)
occurred_ends.append(fact.occurred_end)
mentioned_ats.append(fact.mentioned_at)
contexts.append(_sanitize_text(fact.context))
contexts.append(fact.context)
fact_types.append(fact.fact_type)
# confidence_score is only for opinion facts
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
access_counts.append(0) # Initial access count
metadata_jsons.append(json.dumps(fact.metadata))
chunk_ids.append(fact.chunk_id)
# Use per-fact document_id if available, otherwise fallback to batch-level document_id
@@ -75,16 +76,16 @@ async def insert_facts_batch(
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
$8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[], $15::jsonb[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags_json)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id,
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
@@ -102,6 +103,7 @@ async def insert_facts_batch(
contexts,
fact_types,
confidence_scores,
access_counts,
metadata_jsons,
chunk_ids,
document_ids,
@@ -158,8 +160,7 @@ async def handle_document_tracking(
"""
import hashlib
# Sanitize and calculate content hash
combined_content = _sanitize_text(combined_content) or ""
# Calculate content hash
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
# Always delete old document first if it exists (cascades to units and links)
@@ -754,14 +754,17 @@ async def create_causal_links_batch(
causal_relations_per_fact: List of causal relations for each fact.
Each element is a list of dicts with:
- target_fact_index: Index into unit_ids for the target fact
- relation_type: "caused_by"
- relation_type: "causes", "caused_by", "enables", or "prevents"
- strength: Float in [0.0, 1.0] representing relationship strength
Returns:
Number of causal links created
Causal link type:
- "caused_by": This fact was caused by the target fact
Causal link types:
- "causes": This fact directly causes the target fact (forward causation)
- "caused_by": This fact was caused by the target fact (backward causation)
- "enables": This fact enables/allows the target fact (enablement)
- "prevents": This fact prevents/blocks the target fact (prevention)
"""
if not unit_ids or not causal_relations_per_fact:
return 0
@@ -784,8 +787,8 @@ async def create_causal_links_batch(
relation_type = relation["relation_type"]
strength = relation.get("strength", 1.0)
# Validate relation_type - only "caused_by" is supported (DB constraint)
valid_types = {"caused_by"}
# Validate relation_type - must match database constraint
valid_types = {"causes", "caused_by", "enables", "prevents"}
if relation_type not in valid_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
@@ -8,7 +8,6 @@ import logging
import time
import uuid
from datetime import UTC, datetime
from typing import Any
from ..db_utils import acquire_with_retry
from . import bank_utils
@@ -19,39 +18,6 @@ def utcnow():
return datetime.now(UTC)
def parse_datetime_flexible(value: Any) -> datetime:
"""
Parse a datetime value that could be either a datetime object or an ISO string.
This handles datetime values from both direct Python calls and deserialized JSON
(where datetime objects are serialized as ISO strings).
Args:
value: Either a datetime object or an ISO format string
Returns:
datetime object (timezone-aware)
Raises:
TypeError: If value is neither datetime nor string
ValueError: If string is not a valid ISO datetime
"""
if isinstance(value, datetime):
# Ensure timezone-aware
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
elif isinstance(value, str):
# Parse ISO format string (handles both 'Z' and '+00:00' timezone formats)
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
# Ensure timezone-aware
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
else:
raise TypeError(f"Expected datetime or string, got {type(value).__name__}")
from ..response_models import TokenUsage
from . import (
chunk_storage,
@@ -123,18 +89,10 @@ async def retain_batch(
# Merge item-level tags with document-level tags
item_tags = item.get("tags", []) or []
merged_tags = list(set(item_tags + (document_tags or [])))
# Handle event_date: parse flexibly (handles both datetime objects and ISO strings)
event_date_value = item.get("event_date")
if event_date_value:
event_date_value = parse_datetime_flexible(event_date_value)
else:
event_date_value = utcnow()
content = RetainContent(
content=item["content"],
context=item.get("context", ""),
event_date=event_date_value,
event_date=item.get("event_date") or utcnow(),
metadata=item.get("metadata", {}),
entities=item.get("entities", []),
tags=merged_tags,
@@ -143,8 +101,11 @@ async def retain_batch(
# Step 1: Extract facts from all contents
step_start = time.time()
extract_opinions = fact_type_override == "opinion"
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(contents, llm_config, agent_name)
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, extract_opinions
)
log_buffer.append(
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
)
@@ -86,10 +86,10 @@ class CausalRelation:
"""
Causal relationship between facts.
Represents how one fact was caused by another.
Represents how one fact causes, enables, or prevents another.
"""
relation_type: str # "caused_by"
relation_type: str # "causes", "enables", "prevents", "caused_by"
target_fact_index: int # Index of the target fact in the batch
strength: float = 1.0 # Strength of the causal relationship
@@ -162,7 +162,7 @@ class BFSGraphRetriever(GraphRetriever):
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -216,7 +216,7 @@ class BFSGraphRetriever(GraphRetriever):
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.embedding, mu.fact_type,
mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
mu.document_id, mu.chunk_id, mu.tags,
ml.weight, ml.link_type, ml.from_unit_id
FROM {fq_table("memory_links")} ml
@@ -45,7 +45,7 @@ async def _find_semantic_seeds(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -155,6 +155,7 @@ class LinkExpansionRetriever(GraphRetriever):
all_seeds.extend(temporal_seeds)
if not all_seeds:
logger.debug("[LinkExpansion] No seeds found, returning empty results")
return [], timings
seed_ids = list({s.id for s in all_seeds})
@@ -163,108 +164,36 @@ class LinkExpansionRetriever(GraphRetriever):
# Run entity and causal expansion sequentially on same connection
query_start = time.time()
# For observations, traverse through source_memory_ids to find entity connections.
# Observations don't have direct unit_entities - they inherit entities via their
# source world/experience facts.
#
# Path: observation → source_memory_ids → world fact → entities →
# ALL world facts with those entities → their observations (excluding seeds)
if fact_type == "observation":
# Debug: Check what source_memory_ids exist on seed observations
debug_sources = await conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
seed_ids,
)
source_ids_found = []
for row in debug_sources:
if row["source_memory_ids"]:
source_ids_found.extend(row["source_memory_ids"])
logger.debug(
f"[LinkExpansion] observation graph: {len(seed_ids)} seeds, "
f"{len(source_ids_found)} source_memory_ids found"
)
entity_rows = await conn.fetch(
f"""
WITH seed_sources AS (
-- Get source memory IDs from seed observations
SELECT DISTINCT unnest(source_memory_ids) AS source_id
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND source_memory_ids IS NOT NULL
),
source_entities AS (
-- Get entities from those source memories (filtered by frequency)
SELECT DISTINCT ue.entity_id
FROM seed_sources ss
JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE e.mention_count < $2
),
all_connected_sources AS (
-- Find ALL world facts sharing those entities (don't exclude seed sources)
-- The exclusion happens at the observation level, not the source level
SELECT DISTINCT other_ue.unit_id AS source_id
FROM source_entities se
JOIN {fq_table("unit_entities")} other_ue ON se.entity_id = other_ue.entity_id
)
-- Find observations derived from connected source memories
-- Only exclude the actual seed observations
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
COUNT(DISTINCT cs.source_id)::float AS score
FROM all_connected_sources cs
JOIN {fq_table("memory_units")} mu
ON mu.source_memory_ids @> ARRAY[cs.source_id]
WHERE mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
""",
seed_ids,
self.max_entity_frequency,
budget,
)
logger.debug(f"[LinkExpansion] observation graph: found {len(entity_rows)} connected observations")
else:
# For world/experience facts, use direct entity lookup
entity_rows = await conn.fetch(
f"""
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
COUNT(*)::float AS score
FROM {fq_table("unit_entities")} seed_ue
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[])
AND e.mention_count < $2
AND mu.id != ALL($1::uuid[])
AND mu.fact_type = $3
GROUP BY mu.id
ORDER BY score DESC
LIMIT $4
""",
seed_ids,
self.max_entity_frequency,
fact_type,
budget,
)
entity_rows = await conn.fetch(
f"""
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
COUNT(*)::float AS score
FROM {fq_table("unit_entities")} seed_ue
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[])
AND e.mention_count < $2
AND mu.id != ALL($1::uuid[])
AND mu.fact_type = $3
GROUP BY mu.id
ORDER BY score DESC
LIMIT $4
""",
seed_ids,
self.max_entity_frequency,
fact_type,
budget,
)
causal_rows = await conn.fetch(
f"""
SELECT DISTINCT ON (mu.id)
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight + 1.0 AS score
FROM {fq_table("memory_links")} ml
@@ -282,69 +211,11 @@ class LinkExpansionRetriever(GraphRetriever):
budget,
)
# Fallback: semantic/temporal/entity links from memory_links table
# These are secondary to entity links (via unit_entities) and causal links
# Weight is halved (0.5x) to prioritize primary link types
# Check both directions: seeds -> others AND others -> seeds
fallback_rows = await conn.fetch(
f"""
WITH outgoing AS (
-- Links FROM seeds TO other facts
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('semantic', 'temporal', 'entity')
AND ml.weight >= $2
AND mu.fact_type = $3
AND mu.id != ALL($1::uuid[])
),
incoming AS (
-- Links FROM other facts TO seeds (reverse direction)
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('semantic', 'temporal', 'entity')
AND ml.weight >= $2
AND mu.fact_type = $3
AND mu.id != ALL($1::uuid[])
),
combined AS (
SELECT * FROM outgoing
UNION ALL
SELECT * FROM incoming
)
SELECT DISTINCT ON (id)
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at, embedding,
fact_type, document_id, chunk_id, tags,
(MAX(weight) * 0.5) AS score
FROM combined
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at, embedding,
fact_type, document_id, chunk_id, tags
ORDER BY id, score DESC
LIMIT $4
""",
seed_ids,
self.causal_weight_threshold,
fact_type,
budget,
)
timings.edge_load_time = time.time() - query_start
timings.db_queries = 3
timings.edge_count = len(entity_rows) + len(causal_rows) + len(fallback_rows)
timings.db_queries = 2
timings.edge_count = len(entity_rows) + len(causal_rows)
# Merge results, taking max score per fact
# Priority: entity links (unit_entities) > causal links > fallback links
score_map: dict[str, float] = {}
row_map: dict[str, dict] = {}
@@ -359,12 +230,6 @@ class LinkExpansionRetriever(GraphRetriever):
if fact_id not in row_map:
row_map[fact_id] = dict(row)
for row in fallback_rows:
fact_id = str(row["id"])
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
if fact_id not in row_map:
row_map[fact_id] = dict(row)
# Sort by score and limit
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
rows = [row_map[fact_id] for fact_id in sorted_ids]
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
@@ -116,7 +116,7 @@ async def retrieve_semantic(
results = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -180,7 +180,7 @@ async def retrieve_bm25(
results = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -237,7 +237,7 @@ async def retrieve_semantic_bm25_combined(
results = await conn.fetch(
f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
NULL::float AS bm25_score,
'semantic' AS source,
@@ -249,7 +249,7 @@ async def retrieve_semantic_bm25_combined(
AND (1 - (embedding <=> $1::vector)) >= 0.3
{tags_clause}
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM semantic_ranked
WHERE rn <= $4
@@ -281,7 +281,7 @@ async def retrieve_semantic_bm25_combined(
results = await conn.fetch(
f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
NULL::float AS bm25_score,
'semantic' AS source,
@@ -294,7 +294,7 @@ async def retrieve_semantic_bm25_combined(
{tags_clause}
),
bm25_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
NULL::float AS similarity,
ts_rank_cd(search_vector, to_tsquery('english', $5)) AS bm25_score,
'bm25' AS source,
@@ -306,12 +306,12 @@ async def retrieve_semantic_bm25_combined(
{tags_clause}
),
semantic AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM semantic_ranked WHERE rn <= $4
),
bm25 AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM bm25_ranked WHERE rn <= $4
)
@@ -386,7 +386,7 @@ async def retrieve_temporal_combined(
entry_points = await conn.fetch(
f"""
WITH ranked_entries AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, embedding <=> $1::vector) AS rn
FROM {fq_table("memory_units")}
@@ -406,7 +406,7 @@ async def retrieve_temporal_combined(
AND (1 - (embedding <=> $1::vector)) >= $6
{tags_clause}
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags, similarity
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags, similarity
FROM ranked_entries
WHERE rn <= 10
""",
@@ -486,7 +486,7 @@ async def retrieve_temporal_combined(
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight, ml.link_type, ml.from_unit_id,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_links")} ml
@@ -610,7 +610,7 @@ async def retrieve_temporal(
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -691,7 +691,7 @@ async def retrieve_temporal(
# Batch fetch all neighbors for this batch of nodes
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_links")} ml
@@ -1023,7 +1023,7 @@ async def _get_temporal_entry_points(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
embedding, fact_type, document_id, chunk_id,
access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -0,0 +1,159 @@
"""
Scoring functions for memory search and retrieval.
Includes recency weighting, frequency weighting, temporal proximity,
and similarity calculations used in memory activation and ranking.
"""
from datetime import datetime
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
Args:
vec1: First vector
vec2: Second vector
Returns:
Similarity score between 0 and 1
"""
if len(vec1) != len(vec2):
raise ValueError("Vectors must have same dimension")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
"""
Calculate recency weight using logarithmic decay.
This provides much better differentiation over long time periods compared to
exponential decay. Uses a log-based decay where the half-life parameter controls
when memories reach 50% weight.
Examples:
- Today (0 days): 1.0
- 1 year (365 days): ~0.5 (with default half_life=365)
- 2 years (730 days): ~0.33
- 5 years (1825 days): ~0.17
- 10 years (3650 days): ~0.09
This ensures that 2-year-old and 5-year-old memories have meaningfully
different weights, unlike exponential decay which makes them both ~0.
Args:
days_since: Number of days since the memory was created
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
Returns:
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
return 1.0 / (1.0 + math.log1p(normalized_age))
def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float:
"""
Calculate frequency weight based on access count.
Frequently accessed memories are weighted higher.
Uses logarithmic scaling to avoid over-weighting.
Args:
access_count: Number of times the memory was accessed
max_boost: Maximum multiplier for frequently accessed memories
Returns:
Weight between 1.0 and max_boost
"""
import math
if access_count <= 0:
return 1.0
# Logarithmic scaling: log(access_count + 1) / log(10)
# This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0
normalized = math.log(access_count + 1) / math.log(10)
return 1.0 + min(normalized, max_boost - 1.0)
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
"""
Calculate a single temporal anchor point from a temporal range.
Used for spreading activation - we need a single representative date
to calculate temporal proximity between facts. This simplifies the
range-to-range distance problem.
Strategy: Use midpoint of the range for balanced representation.
Args:
occurred_start: Start of temporal range
occurred_end: End of temporal range
Returns:
Single datetime representing the temporal anchor (midpoint)
Examples:
- Point event (July 14): start=July 14, end=July 14 anchor=July 14
- Month range (February): start=Feb 1, end=Feb 28 anchor=Feb 14
- Year range (2023): start=Jan 1, end=Dec 31 anchor=July 1
"""
# Calculate midpoint
time_delta = occurred_end - occurred_start
midpoint = occurred_start + (time_delta / 2)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
Used for spreading activation to determine how "close" two facts are
in time. Uses logarithmic decay so that temporal similarity doesn't
drop off too quickly.
Args:
anchor_a: Temporal anchor of first fact
anchor_b: Temporal anchor of second fact
half_life_days: Number of days for proximity to reach 0.5
(default: 30 days = 1 month)
Returns:
Proximity score in [0, 1] where:
- 1.0 = same day
- 0.5 = ~half_life days apart
- 0.0 = very distant in time
Examples:
- Same day: 1.0
- 1 week apart (half_life=30): ~0.7
- 1 month apart (half_life=30): ~0.5
- 1 year apart (half_life=30): ~0.2
"""
import math
days_apart = abs((anchor_a - anchor_b).days)
if days_apart == 0:
return 1.0
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
# Similar to calculate_recency_weight but for proximity between events
normalized_distance = days_apart / half_life_days
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
return proximity
@@ -85,6 +85,7 @@ class NodeVisit(BaseModel):
text: str = Field(description="Memory unit text content")
context: str = Field(description="Memory unit context")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
access_count: int = Field(description="Number of times accessed before this search")
# How this node was reached
is_entry_point: bool = Field(description="Whether this is an entry point")
@@ -136,6 +136,7 @@ class SearchTracer:
text: str,
context: str,
event_date: datetime | None,
access_count: int,
is_entry_point: bool,
parent_node_id: str | None,
link_type: Literal["temporal", "semantic", "entity"] | None,
@@ -154,6 +155,7 @@ class SearchTracer:
text: Memory unit text
context: Memory unit context
event_date: When the memory occurred
access_count: Access count before this search
is_entry_point: Whether this is an entry point
parent_node_id: Node that led here (None for entry points)
link_type: Type of link from parent
@@ -192,6 +194,7 @@ class SearchTracer:
text=text,
context=context,
event_date=event_date,
access_count=access_count,
is_entry_point=is_entry_point,
parent_node_id=parent_node_id,
link_type=link_type,
@@ -330,8 +333,8 @@ class SearchTracer:
RetrievalResult(
rank=rank,
node_id=doc_id,
text=data.get("text") or "",
context=data.get("context") or "",
text=data.get("text", ""),
context=data.get("context", ""),
event_date=data.get("event_date"),
fact_type=data.get("fact_type") or fact_type,
score=score,
@@ -46,6 +46,7 @@ class RetrievalResult:
mentioned_at: datetime | None = None
document_id: str | None = None
chunk_id: str | None = None
access_count: int = 0
embedding: list[float] | None = None
tags: list[str] | None = None # Visibility scope tags
@@ -70,6 +71,7 @@ class RetrievalResult:
mentioned_at=row.get("mentioned_at"),
document_id=row.get("document_id"),
chunk_id=row.get("chunk_id"),
access_count=row.get("access_count", 0),
embedding=row.get("embedding"),
tags=row.get("tags"),
similarity=row.get("similarity"),
@@ -154,6 +156,7 @@ class ScoredResult:
"mentioned_at": self.retrieval.mentioned_at,
"document_id": self.retrieval.document_id,
"chunk_id": self.retrieval.chunk_id,
"access_count": self.retrieval.access_count,
"embedding": self.retrieval.embedding,
"tags": self.retrieval.tags,
"semantic_similarity": self.retrieval.similarity,
+202 -127
View File
@@ -1,40 +1,31 @@
"""
Task backend for distributed task processing.
Abstract task backend for running async tasks.
This provides an abstraction for task storage and execution:
- BrokerTaskBackend: Uses PostgreSQL as broker (production)
- SyncTaskBackend: Executes tasks immediately (testing/embedded)
This provides an abstraction that can be adapted to different execution models:
- AsyncIO queue (default implementation)
- Pub/Sub architectures (future)
- Message brokers (future)
"""
import json
import asyncio
import logging
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import asyncpg
from typing import Any
logger = logging.getLogger(__name__)
def fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
if schema:
return f'"{schema}".{table}'
return table
class TaskBackend(ABC):
"""
Abstract base class for task execution backends.
Implementations must:
1. Store/publish task events (as serializable dicts)
2. Execute tasks through a provided executor callback (optional)
2. Execute tasks through a provided executor callback
The backend treats tasks as pure dictionaries that can be serialized
and stored in the database. The executor (typically MemoryEngine.execute_task)
and sent over the network. The executor (typically MemoryEngine.execute_task)
receives the dict and routes it to the appropriate handler.
"""
@@ -55,7 +46,7 @@ class TaskBackend(ABC):
@abstractmethod
async def initialize(self):
"""
Initialize the backend (e.g., connect to database).
Initialize the backend (e.g., start workers, connect to broker).
"""
pass
@@ -72,7 +63,7 @@ class TaskBackend(ABC):
@abstractmethod
async def shutdown(self):
"""
Shutdown the backend gracefully.
Shutdown the backend gracefully (e.g., stop workers, close connections).
"""
pass
@@ -102,8 +93,9 @@ class SyncTaskBackend(TaskBackend):
"""
Synchronous task backend that executes tasks immediately.
This is useful for tests and embedded/CLI usage where we don't want
background workers. Tasks are executed inline rather than being queued.
This is useful for embedded/CLI usage where we don't want background
workers that prevent clean exit. Tasks are executed inline rather than
being queued.
"""
async def initialize(self):
@@ -129,138 +121,221 @@ class SyncTaskBackend(TaskBackend):
logger.debug("SyncTaskBackend shutdown")
class BrokerTaskBackend(TaskBackend):
class NoopTaskBackend(TaskBackend):
"""
Task backend using PostgreSQL as broker.
No-op task backend that discards all tasks.
submit_task() stores task_payload in async_operations table.
Actual polling and execution is handled separately by WorkerPoller.
This backend is used by the API to store tasks. Workers poll
the database separately to claim and execute tasks.
This is useful for tests where background task execution is not needed
and would only slow down the test suite.
"""
def __init__(
self,
pool_getter: Callable[[], "asyncpg.Pool"],
schema: str | None = None,
schema_getter: Callable[[], str | None] | None = None,
):
"""
Initialize the broker task backend.
Args:
pool_getter: Callable that returns the asyncpg connection pool
schema: Database schema for multi-tenant support (optional, static)
schema_getter: Callable that returns current schema dynamically (optional).
If set, takes precedence over static schema for submit_task.
"""
super().__init__()
self._pool_getter = pool_getter
self._schema = schema
self._schema_getter = schema_getter
async def initialize(self):
"""Initialize the backend."""
"""No-op."""
self._initialized = True
logger.info("BrokerTaskBackend initialized")
logger.debug("NoopTaskBackend initialized")
async def submit_task(self, task_dict: dict[str, Any]):
"""Discard the task (do nothing)."""
pass
async def shutdown(self):
"""No-op."""
self._initialized = False
logger.debug("NoopTaskBackend shutdown")
class AsyncIOQueueBackend(TaskBackend):
"""
Task backend implementation using asyncio queues.
This is the default implementation that uses in-process asyncio queues
and a periodic consumer worker.
"""
def __init__(self, batch_size: int = 10, batch_interval: float = 1.0):
"""
Initialize AsyncIO queue backend.
Args:
batch_size: Maximum number of tasks to process in one batch
batch_interval: Maximum time (seconds) to wait before processing batch
"""
super().__init__()
self._queue: asyncio.Queue | None = None
self._worker_task: asyncio.Task | None = None
self._shutdown_event: asyncio.Event | None = None
self._batch_size = batch_size
self._batch_interval = batch_interval
self._in_flight_count = 0
self._in_flight_lock = asyncio.Lock()
async def initialize(self):
"""Initialize the queue and start the worker."""
if self._initialized:
return
self._queue = asyncio.Queue()
self._shutdown_event = asyncio.Event()
self._worker_task = asyncio.create_task(self._worker())
self._initialized = True
logger.info("AsyncIOQueueBackend initialized")
async def submit_task(self, task_dict: dict[str, Any]):
"""
Store task payload in async_operations table.
The task_dict should contain an 'operation_id' if updating an existing
operation record, otherwise a new operation will be created.
Submit a task by putting it in the queue.
Args:
task_dict: Task dictionary to store (must be JSON serializable)
task_dict: Task dictionary to execute
"""
if not self._initialized:
await self.initialize()
pool = self._pool_getter()
operation_id = task_dict.get("operation_id")
task_type = task_dict.get("type", "unknown")
bank_id = task_dict.get("bank_id")
# Custom encoder to handle datetime objects
from datetime import datetime
def datetime_encoder(obj):
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
payload_json = json.dumps(task_dict, default=datetime_encoder)
schema = self._schema_getter() if self._schema_getter else self._schema
table = fq_table("async_operations", schema)
if operation_id:
# Update existing operation with task payload
await pool.execute(
f"""
UPDATE {table}
SET task_payload = $1::jsonb, updated_at = now()
WHERE operation_id = $2
""",
payload_json,
operation_id,
)
logger.debug(f"Updated task payload for operation {operation_id}")
else:
# Insert new operation (for tasks without pre-created records)
# e.g., access_count_update tasks
import uuid
new_id = uuid.uuid4()
await pool.execute(
f"""
INSERT INTO {table} (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, $3, 'pending', $4::jsonb)
""",
new_id,
bank_id,
task_type,
payload_json,
)
logger.debug(f"Created new operation {new_id} for task type {task_type}")
async def shutdown(self):
"""Shutdown the backend."""
self._initialized = False
logger.info("BrokerTaskBackend shutdown")
await self._queue.put(task_dict)
async def wait_for_pending_tasks(self, timeout: float = 120.0):
"""
Wait for pending tasks to be processed.
Wait for all pending tasks in the queue and in-flight tasks to complete.
In the broker model, this polls the database to check if tasks
for this process have been completed. This is useful in tests
when worker_enabled=True (API processes its own tasks).
This is useful in tests to ensure background tasks complete before assertions.
Args:
timeout: Maximum time to wait in seconds
timeout: Maximum time to wait in seconds (default 120s for long-running tasks)
"""
import asyncio
pool = self._pool_getter()
schema = self._schema_getter() if self._schema_getter else self._schema
table = fq_table("async_operations", schema)
if not self._initialized or self._queue is None:
return
# Wait for queue to be empty AND no in-flight tasks
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < timeout:
# Check if there are any pending tasks with payloads
count = await pool.fetchval(
f"""
SELECT COUNT(*) FROM {table}
WHERE status = 'pending' AND task_payload IS NOT NULL
"""
)
async with self._in_flight_lock:
in_flight = self._in_flight_count
if count == 0:
if self._queue.empty() and in_flight == 0:
# Queue is empty and no tasks in flight, we're done
return
# Wait a bit before checking again
await asyncio.sleep(0.5)
logger.warning(f"Timeout waiting for pending tasks after {timeout}s")
async def shutdown(self):
"""Shutdown the worker and drain the queue."""
if not self._initialized:
return
logger.info("Shutting down AsyncIOQueueBackend...")
# Signal shutdown
self._shutdown_event.set()
# Cancel worker
if self._worker_task is not None:
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass # Worker cancelled successfully
self._initialized = False
logger.info("AsyncIOQueueBackend shutdown complete")
async def _execute_task_with_tracking(self, task_dict: dict[str, Any]):
"""Execute a task and track its in-flight status."""
async with self._in_flight_lock:
self._in_flight_count += 1
try:
await self._execute_task(task_dict)
finally:
async with self._in_flight_lock:
self._in_flight_count -= 1
async def _execute_task_no_tracking(self, task_dict: dict[str, Any]):
"""Execute a task without in-flight tracking (tracking done at batch level)."""
await self._execute_task(task_dict)
def _get_queue_stats(self) -> tuple[int, dict[str, int]]:
"""Get current queue size and bank_id distribution."""
queue_size = self._queue.qsize() if self._queue else 0
bank_distribution: dict[str, int] = {}
if queue_size > 0 and self._queue:
# Peek at queue items without removing them
# Note: This is a snapshot and may not be perfectly accurate due to concurrency
try:
# Access internal deque for logging purposes only
items = list(self._queue._queue) # type: ignore[attr-defined]
for item in items:
bank_id = item.get("bank_id", "unknown")
bank_distribution[bank_id] = bank_distribution.get(bank_id, 0) + 1
except Exception:
pass # Queue access failed, return empty distribution
return queue_size, bank_distribution
async def _worker(self):
"""
Background worker that processes tasks in batches.
Collects tasks for up to batch_interval seconds or batch_size items,
then processes them.
"""
while not self._shutdown_event.is_set():
try:
# Collect tasks for batching
tasks = []
deadline = asyncio.get_event_loop().time() + self._batch_interval
while len(tasks) < self._batch_size and asyncio.get_event_loop().time() < deadline:
try:
remaining_time = max(0.1, deadline - asyncio.get_event_loop().time())
task_dict = await asyncio.wait_for(self._queue.get(), timeout=remaining_time)
# Track task as in-flight immediately when picked up from queue
# This prevents wait_for_pending_tasks from returning too early
async with self._in_flight_lock:
self._in_flight_count += 1
tasks.append(task_dict)
except TimeoutError:
break
# Process batch
if tasks:
# Log batch start with queue stats
queue_size, bank_distribution = self._get_queue_stats()
# Summarize batch by task type and bank
batch_summary: dict[str, dict[str, int]] = {}
for task_dict in tasks:
task_type = task_dict.get("type", "unknown")
bank_id = task_dict.get("bank_id", "unknown")
if task_type not in batch_summary:
batch_summary[task_type] = {}
batch_summary[task_type][bank_id] = batch_summary[task_type].get(bank_id, 0) + 1
# Build log message
batch_parts = []
for task_type, banks in sorted(batch_summary.items()):
bank_str = ", ".join(f"{b}:{c}" for b, c in sorted(banks.items()))
batch_parts.append(f"{task_type}[{bank_str}]")
batch_str = ", ".join(batch_parts)
if queue_size > 0:
pending_str = ", ".join(f"{k}:{v}" for k, v in sorted(bank_distribution.items()))
logger.info(
f"Processing {len(tasks)} tasks: {batch_str} (pending={queue_size} [{pending_str}])"
)
else:
logger.info(f"Processing {len(tasks)} tasks: {batch_str}")
# Execute tasks concurrently (in_flight already tracked when picked up)
await asyncio.gather(
*[self._execute_task_no_tracking(task_dict) for task_dict in tasks], return_exceptions=True
)
# Decrement in_flight count after all tasks complete
async with self._in_flight_lock:
self._in_flight_count -= len(tasks)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Worker error: {e}")
await asyncio.sleep(1) # Backoff on error
+154
View File
@@ -19,6 +19,7 @@ async def extract_facts(
context: str = "",
llm_config: "LLMConfig" = None,
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list["Fact"], list[tuple[str, int]]]:
"""
Extract semantic facts from text using LLM.
@@ -35,6 +36,7 @@ async def extract_facts(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name to help identify agent-related facts
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
Tuple of (facts, chunks) where:
@@ -53,6 +55,7 @@ async def extract_facts(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
if not facts:
@@ -62,3 +65,154 @@ async def extract_facts(
return [], chunks
return facts, chunks
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
Args:
vec1: First vector
vec2: Second vector
Returns:
Similarity score between 0 and 1
"""
if len(vec1) != len(vec2):
raise ValueError("Vectors must have same dimension")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
"""
Calculate recency weight using logarithmic decay.
This provides much better differentiation over long time periods compared to
exponential decay. Uses a log-based decay where the half-life parameter controls
when memories reach 50% weight.
Examples:
- Today (0 days): 1.0
- 1 year (365 days): ~0.5 (with default half_life=365)
- 2 years (730 days): ~0.33
- 5 years (1825 days): ~0.17
- 10 years (3650 days): ~0.09
This ensures that 2-year-old and 5-year-old memories have meaningfully
different weights, unlike exponential decay which makes them both ~0.
Args:
days_since: Number of days since the memory was created
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
Returns:
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
return 1.0 / (1.0 + math.log1p(normalized_age))
def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float:
"""
Calculate frequency weight based on access count.
Frequently accessed memories are weighted higher.
Uses logarithmic scaling to avoid over-weighting.
Args:
access_count: Number of times the memory was accessed
max_boost: Maximum multiplier for frequently accessed memories
Returns:
Weight between 1.0 and max_boost
"""
import math
if access_count <= 0:
return 1.0
# Logarithmic scaling: log(access_count + 1) / log(10)
# This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0
normalized = math.log(access_count + 1) / math.log(10)
return 1.0 + min(normalized, max_boost - 1.0)
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
"""
Calculate a single temporal anchor point from a temporal range.
Used for spreading activation - we need a single representative date
to calculate temporal proximity between facts. This simplifies the
range-to-range distance problem.
Strategy: Use midpoint of the range for balanced representation.
Args:
occurred_start: Start of temporal range
occurred_end: End of temporal range
Returns:
Single datetime representing the temporal anchor (midpoint)
Examples:
- Point event (July 14): start=July 14, end=July 14 anchor=July 14
- Month range (February): start=Feb 1, end=Feb 28 anchor=Feb 14
- Year range (2023): start=Jan 1, end=Dec 31 anchor=July 1
"""
# Calculate midpoint
time_delta = occurred_end - occurred_start
midpoint = occurred_start + (time_delta / 2)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
Used for spreading activation to determine how "close" two facts are
in time. Uses logarithmic decay so that temporal similarity doesn't
drop off too quickly.
Args:
anchor_a: Temporal anchor of first fact
anchor_b: Temporal anchor of second fact
half_life_days: Number of days for proximity to reach 0.5
(default: 30 days = 1 month)
Returns:
Proximity score in [0, 1] where:
- 1.0 = same day
- 0.5 = ~half_life days apart
- 0.0 = very distant in time
Examples:
- Same day: 1.0
- 1 week apart (half_life=30): ~0.7
- 1 month apart (half_life=30): ~0.5
- 1 year apart (half_life=30): ~0.2
"""
import math
days_apart = abs((anchor_a - anchor_b).days)
if days_apart == 0:
return 1.0
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
# Similar to calculate_recency_weight but for proximity between events
normalized_distance = days_apart / half_life_days
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
return proximity
@@ -21,15 +21,6 @@ from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionC
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
# Mental Model operations
MentalModelGetContext,
MentalModelGetResult,
MentalModelRefreshContext,
MentalModelRefreshResult,
# Core operations
OperationValidationError,
OperationValidatorExtension,
RecallContext,
@@ -42,7 +33,6 @@ from hindsight_api.extensions.operation_validator import (
)
from hindsight_api.extensions.tenant import (
AuthenticationError,
Tenant,
TenantContext,
TenantExtension,
)
@@ -57,7 +47,7 @@ __all__ = [
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# Operation Validator - Core
# Operation Validator
"OperationValidationError",
"OperationValidatorExtension",
"RecallContext",
@@ -67,19 +57,10 @@ __all__ = [
"RetainContext",
"RetainResult",
"ValidationResult",
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
# Operation Validator - Mental Model
"MentalModelGetContext",
"MentalModelGetResult",
"MentalModelRefreshContext",
"MentalModelRefreshResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"AuthenticationError",
"RequestContext",
"Tenant",
"TenantContext",
"TenantExtension",
]
@@ -1,59 +1,20 @@
"""Built-in tenant extension implementations."""
from hindsight_api.config import get_config
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
class DefaultTenantExtension(TenantExtension):
"""
Default single-tenant extension with no authentication.
This is the default extension used when no tenant extension is configured.
It provides single-tenant behavior using the configured schema from
HINDSIGHT_API_DATABASE_SCHEMA (defaults to 'public').
Features:
- No authentication required (passes all requests)
- Uses configured schema from environment
- Perfect for single-tenant deployments without auth
Configuration:
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
This is automatically enabled by default. To use custom authentication,
configure a different tenant extension:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
"""
def __init__(self, config: dict[str, str]):
super().__init__(config)
# Cache the schema at initialization for consistency
# Support explicit schema override via config, otherwise use environment
self._schema = config.get("schema", get_config().database_schema)
async def authenticate(self, context: RequestContext) -> TenantContext:
"""Return configured schema without any authentication."""
return TenantContext(schema_name=self._schema)
async def list_tenants(self) -> list[Tenant]:
"""Return configured schema for single-tenant setup."""
return [Tenant(schema=self._schema)]
class ApiKeyTenantExtension(TenantExtension):
"""
Built-in tenant extension that validates API key against an environment variable.
This is a simple implementation that:
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
2. Returns the configured schema (HINDSIGHT_API_DATABASE_SCHEMA, default 'public')
for all authenticated requests
2. Returns 'public' as the schema for all authenticated requests
Configuration:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
For multi-tenant setups with separate schemas per tenant, implement a custom
TenantExtension that looks up the schema based on the API key or token claims.
@@ -66,11 +27,7 @@ class ApiKeyTenantExtension(TenantExtension):
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
async def authenticate(self, context: RequestContext) -> TenantContext:
"""Validate API key and return configured schema context."""
"""Validate API key and return public schema context."""
if context.api_key != self.expected_api_key:
raise AuthenticationError("Invalid API key")
return TenantContext(schema_name=get_config().database_schema)
async def list_tenants(self) -> list[Tenant]:
"""Return configured schema for single-tenant setup."""
return [Tenant(schema=get_config().database_schema)]
return TenantContext(schema_name="public")
@@ -1,4 +1,4 @@
"""Operation Validator Extension for validating retain/recall/reflect/consolidate operations."""
"""Operation Validator Extension for validating retain/recall/reflect operations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -97,19 +97,6 @@ class ReflectContext:
context: str | None = None
# =============================================================================
# Consolidation Pre-operation Context
# =============================================================================
@dataclass
class ConsolidateContext:
"""Context for a consolidation operation validation (pre-operation)."""
bank_id: str
request_context: "RequestContext"
# =============================================================================
# Post-operation Contexts (includes results)
# =============================================================================
@@ -177,79 +164,9 @@ class ReflectResultContext:
error: str | None = None
# =============================================================================
# Consolidation Post-operation Context
# =============================================================================
@dataclass
class ConsolidateResult:
"""Result context for post-consolidation hook."""
bank_id: str
request_context: "RequestContext"
# Result
processed: int = 0
created: int = 0
updated: int = 0
success: bool = True
error: str | None = None
# =============================================================================
# Mental Model Contexts
# =============================================================================
@dataclass
class MentalModelGetContext:
"""Context for a mental model GET operation validation (pre-operation)."""
bank_id: str
mental_model_id: str
request_context: "RequestContext"
@dataclass
class MentalModelRefreshContext:
"""Context for a mental model refresh/create operation validation (pre-operation)."""
bank_id: str
mental_model_id: str | None # None for create (not yet assigned)
request_context: "RequestContext"
@dataclass
class MentalModelGetResult:
"""Result context for post-mental-model-GET hook."""
bank_id: str
mental_model_id: str
request_context: "RequestContext"
output_tokens: int # tokens in the returned content
success: bool = True
error: str | None = None
@dataclass
class MentalModelRefreshResult:
"""Result context for post-mental-model-refresh hook."""
bank_id: str
mental_model_id: str
request_context: "RequestContext"
query_tokens: int # tokens in source_query
output_tokens: int # tokens in generated content
context_tokens: int # tokens in context (if any)
facts_used: int # facts referenced in based_on
mental_models_used: int # mental models referenced in based_on
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect/consolidate operations.
Validates and hooks into retain/recall/reflect operations.
This extension allows implementing custom logic such as:
- Rate limiting (pre-operation)
@@ -268,13 +185,9 @@ class OperationValidatorExtension(Extension, ABC):
-> config = {"max_requests": "100"}
Hook execution order:
1. validate_* (pre-operation)
1. validate_retain/validate_recall/validate_reflect (pre-operation)
2. [operation executes]
3. on_*_complete (post-operation)
Supported operations:
- retain, recall, reflect (core memory operations)
- consolidate (mental models consolidation)
3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
"""
# =========================================================================
@@ -412,122 +325,3 @@ class OperationValidatorExtension(Extension, ABC):
- error: Error message (if failed)
"""
pass
# =========================================================================
# Consolidation - Pre-operation validation hook (optional - override to implement)
# =========================================================================
async def validate_consolidate(self, ctx: ConsolidateContext) -> ValidationResult:
"""
Validate a consolidation operation before execution.
Override to implement custom validation logic for consolidation.
Args:
ctx: Context containing:
- bank_id: Bank identifier
- request_context: Request context with auth info
Returns:
ValidationResult indicating whether the operation is allowed.
"""
return ValidationResult.accept()
# =========================================================================
# Consolidation - Post-operation hook (optional - override to implement)
# =========================================================================
async def on_consolidate_complete(self, result: ConsolidateResult) -> None:
"""
Called after a consolidation operation completes (success or failure).
Override to implement post-operation logic such as usage tracking or audit logging.
Args:
result: Result context containing:
- bank_id: Bank identifier
- processed: Number of memories processed
- created: Number of mental models created
- updated: Number of mental models updated
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
# =========================================================================
# Mental Model - Pre-operation validation hook (optional - override to implement)
# =========================================================================
async def validate_mental_model_get(self, ctx: MentalModelGetContext) -> ValidationResult:
"""
Validate a mental model GET operation before execution.
Override to implement custom validation logic for mental model retrieval.
Args:
ctx: Context containing:
- bank_id: Bank identifier
- mental_model_id: Mental model identifier
- request_context: Request context with auth info
Returns:
ValidationResult indicating whether the operation is allowed.
"""
return ValidationResult.accept()
async def validate_mental_model_refresh(self, ctx: MentalModelRefreshContext) -> ValidationResult:
"""
Validate a mental model refresh/create operation before execution.
Override to implement custom validation logic for mental model refresh.
Args:
ctx: Context containing:
- bank_id: Bank identifier
- mental_model_id: Mental model identifier (None for create)
- request_context: Request context with auth info
Returns:
ValidationResult indicating whether the operation is allowed.
"""
return ValidationResult.accept()
# =========================================================================
# Mental Model - Post-operation hooks (optional - override to implement)
# =========================================================================
async def on_mental_model_get_complete(self, result: MentalModelGetResult) -> None:
"""
Called after a mental model GET operation completes (success or failure).
Override to implement post-operation logic such as tracking or audit logging.
Args:
result: Result context containing:
- bank_id: Bank identifier
- mental_model_id: Mental model identifier
- output_tokens: Token count of the returned content
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
async def on_mental_model_refresh_complete(self, result: MentalModelRefreshResult) -> None:
"""
Called after a mental model refresh operation completes (success or failure).
Override to implement post-operation logic such as tracking or audit logging.
Args:
result: Result context containing:
- bank_id: Bank identifier
- mental_model_id: Mental model identifier
- query_tokens: Tokens in source_query
- output_tokens: Tokens in generated content
- context_tokens: Tokens in context
- facts_used: Number of facts referenced
- mental_models_used: Number of mental models referenced
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
@@ -28,18 +28,6 @@ class TenantContext:
schema_name: str
@dataclass
class Tenant:
"""
Represents a tenant for worker discovery.
Used by list_tenants() to return tenant information including
the PostgreSQL schema name for database operations.
"""
schema: str
class TenantExtension(Extension, ABC):
"""
Extension for multi-tenancy and API key authentication.
@@ -73,17 +61,3 @@ class TenantExtension(Extension, ABC):
AuthenticationError: If authentication fails.
"""
...
@abstractmethod
async def list_tenants(self) -> list[Tenant]:
"""
List all tenants that should be processed by workers.
This method is used by the worker to discover all tenants that need
task polling. Workers will poll for pending tasks in each tenant's schema.
Returns:
List of Tenant objects containing schema information.
For single-tenant setups, return [Tenant(schema="public")].
"""
...
+30 -51
View File
@@ -20,13 +20,14 @@ import warnings
import uvicorn
from . import MemoryEngine, __version__
from . import MemoryEngine
from .api import create_app
from .banner import print_banner
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
DaemonLock,
IdleTimeoutMiddleware,
daemonize,
)
@@ -135,15 +136,30 @@ def main():
# Daemon mode handling
if args.daemon:
# Use port from args (may be custom for profiles)
if args.port == config.port: # No custom port specified
args.port = DEFAULT_DAEMON_PORT
# Use fixed daemon port
args.port = DEFAULT_DAEMON_PORT
args.host = "127.0.0.1" # Only bind to localhost for security
# Check if another daemon is already running
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
sys.exit(1)
# Fork into background
# No lockfile needed - port binding prevents duplicate daemons
daemonize()
# Re-acquire lock in child process
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
sys.exit(1)
# Register cleanup to release lock
def release_lock():
daemon_lock.release()
atexit.register(release_lock)
# Print banner (not in daemon mode)
if not args.daemon:
print()
@@ -154,56 +170,27 @@ def main():
if args.log_level != config.log_level:
config = HindsightConfig(
database_url=config.database_url,
database_schema=config.database_schema,
llm_provider=config.llm_provider,
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
llm_max_concurrent=config.llm_max_concurrent,
llm_max_retries=config.llm_max_retries,
llm_initial_backoff=config.llm_initial_backoff,
llm_max_backoff=config.llm_max_backoff,
llm_timeout=config.llm_timeout,
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,
retain_llm_base_url=config.retain_llm_base_url,
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
retain_llm_max_retries=config.retain_llm_max_retries,
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
retain_llm_max_backoff=config.retain_llm_max_backoff,
retain_llm_timeout=config.retain_llm_timeout,
reflect_llm_provider=config.reflect_llm_provider,
reflect_llm_api_key=config.reflect_llm_api_key,
reflect_llm_model=config.reflect_llm_model,
reflect_llm_base_url=config.reflect_llm_base_url,
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
reflect_llm_max_retries=config.reflect_llm_max_retries,
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
reflect_llm_timeout=config.reflect_llm_timeout,
consolidation_llm_provider=config.consolidation_llm_provider,
consolidation_llm_api_key=config.consolidation_llm_api_key,
consolidation_llm_model=config.consolidation_llm_model,
consolidation_llm_base_url=config.consolidation_llm_base_url,
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
consolidation_llm_timeout=config.consolidation_llm_timeout,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
embeddings_tei_url=config.embeddings_tei_url,
embeddings_openai_base_url=config.embeddings_openai_base_url,
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
reranker_provider=config.reranker_provider,
reranker_local_model=config.reranker_local_model,
reranker_local_force_cpu=config.reranker_local_force_cpu,
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
reranker_tei_url=config.reranker_tei_url,
reranker_tei_batch_size=config.reranker_tei_batch_size,
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
@@ -212,20 +199,18 @@ def main():
host=args.host,
port=args.port,
log_level=args.log_level,
log_format=config.log_format,
mcp_enabled=config.mcp_enabled,
graph_retriever=config.graph_retriever,
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
recall_max_concurrent=config.recall_max_concurrent,
recall_connection_budget=config.recall_connection_budget,
observation_min_facts=config.observation_min_facts,
observation_top_entities=config.observation_top_entities,
retain_max_completion_tokens=config.retain_max_completion_tokens,
retain_chunk_size=config.retain_chunk_size,
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_custom_instructions=config.retain_custom_instructions,
enable_observations=config.enable_observations,
consolidation_batch_size=config.consolidation_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens,
retain_observations_async=config.retain_observations_async,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
run_migrations_on_startup=config.run_migrations_on_startup,
@@ -233,13 +218,9 @@ def main():
db_pool_max_size=config.db_pool_max_size,
db_command_timeout=config.db_command_timeout,
db_acquire_timeout=config.db_acquire_timeout,
worker_enabled=config.worker_enabled,
worker_id=config.worker_id,
worker_poll_interval_ms=config.worker_poll_interval_ms,
worker_max_retries=config.worker_max_retries,
worker_http_port=config.worker_http_port,
worker_max_slots=config.worker_max_slots,
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
task_backend=config.task_backend,
task_backend_memory_batch_size=config.task_backend_memory_batch_size,
task_backend_memory_batch_interval=config.task_backend_memory_batch_interval,
reflect_max_iterations=config.reflect_max_iterations,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
)
@@ -346,13 +327,11 @@ def main():
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
version=__version__,
)
# Start idle checker in daemon mode
if idle_middleware is not None:
# Start the idle checker in a background thread with its own event loop
import logging
import threading
def run_idle_checker():
@@ -363,12 +342,12 @@ def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(idle_middleware._check_idle())
except Exception as e:
logging.error(f"Idle checker error: {e}", exc_info=True)
except Exception:
pass
threading.Thread(target=run_idle_checker, daemon=True).start()
uvicorn.run(**uvicorn_config)
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
if __name__ == "__main__":
+52 -11
View File
@@ -44,6 +44,7 @@ import os
import sys
from mcp.server.fastmcp import FastMCP
from mcp.types import Icon
from hindsight_api.config import (
DEFAULT_MCP_LOCAL_BANK_ID,
@@ -52,7 +53,6 @@ from hindsight_api.config import (
ENV_MCP_INSTRUCTIONS,
ENV_MCP_LOCAL_BANK_ID,
)
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
# Configure logging - default to warning to avoid polluting stderr during MCP init
# MCP clients interpret stderr output as errors, so we suppress INFO logs by default
@@ -85,6 +85,9 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
"""
# Import here to avoid slow startup if just checking --help
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Create memory engine with pg0 embedded database if not provided
if memory is None:
@@ -102,17 +105,55 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
mcp = FastMCP("hindsight")
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=lambda: bank_id,
include_bank_id_param=False, # Local MCP uses fixed bank_id
tools={"retain", "recall"}, # Local MCP only has retain and recall
retain_description=retain_description,
recall_description=recall_description,
retain_fire_and_forget=True, # Local MCP uses fire-and-forget pattern
)
@mcp.tool(description=retain_description)
async def retain(content: str, context: str = "general") -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
"""
import asyncio
register_mcp_tools(mcp, memory, config)
async def _retain():
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": content, "context": context}],
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
# Fire and forget - don't block on memory storage
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
@mcp.tool(description=recall_description)
async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget level - "low", "mid", or "high" (default: "low")
"""
try:
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
search_result = await memory.recall_async(
bank_id=bank_id,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=budget_enum,
max_tokens=max_tokens,
request_context=RequestContext(),
)
return search_result.model_dump()
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return {"error": str(e), "results": []}
return mcp
-513
View File
@@ -1,513 +0,0 @@
"""Shared MCP tool implementations for Hindsight.
This module provides the core tool logic used by both:
- mcp_local.py (stdio transport for Claude Code)
- api/mcp.py (HTTP transport for API server)
"""
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.config import (
DEFAULT_MCP_RECALL_DESCRIPTION,
DEFAULT_MCP_RETAIN_DESCRIPTION,
)
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
@dataclass
class MCPToolsConfig:
"""Configuration for MCP tools registration."""
# How to resolve bank_id for operations
bank_id_resolver: Callable[[], str | None]
# How to resolve API key for tenant auth (optional)
api_key_resolver: Callable[[], str | None] | None = None
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
# Which tools to register
tools: set[str] | None = None # None means all tools
# Custom descriptions (if None, uses defaults)
retain_description: str | None = None
recall_description: str | None = None
# Retain behavior
retain_fire_and_forget: bool = False # If True, use asyncio.create_task pattern
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
"""Create RequestContext with API key from resolver if available.
This enables tenant auth to work with MCP tools by propagating
the Bearer token from the MCP middleware to the memory engine.
"""
api_key = config.api_key_resolver() if config.api_key_resolver else None
return RequestContext(api_key=api_key)
def parse_timestamp(timestamp: str) -> datetime | None:
"""Parse an ISO format timestamp string.
Args:
timestamp: ISO format timestamp (e.g., '2024-01-15T10:30:00Z')
Returns:
Parsed datetime or None if invalid
Raises:
ValueError: If timestamp format is invalid
"""
try:
return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError as e:
raise ValueError(
f"Invalid timestamp format '{timestamp}'. "
"Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'"
) from e
def build_content_dict(
content: str,
context: str,
timestamp: str | None = None,
) -> tuple[dict[str, Any], str | None]:
"""Build a content dict for retain operations.
Args:
content: The memory content
context: Category for the memory
timestamp: Optional ISO timestamp
Returns:
Tuple of (content_dict, error_message). error_message is None if successful.
"""
content_dict: dict[str, Any] = {"content": content, "context": context}
if timestamp:
try:
parsed_timestamp = parse_timestamp(timestamp)
content_dict["event_date"] = parsed_timestamp
except ValueError as e:
return {}, str(e)
return content_dict, None
def register_mcp_tools(
mcp: FastMCP,
memory: MemoryEngine,
config: MCPToolsConfig,
) -> None:
"""Register MCP tools on a FastMCP server.
Args:
mcp: FastMCP server instance
memory: MemoryEngine instance
config: Tool configuration
"""
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
if "retain" in tools_to_register:
_register_retain(mcp, memory, config)
if "recall" in tools_to_register:
_register_recall(mcp, memory, config)
if "reflect" in tools_to_register:
_register_reflect(mcp, memory, config)
if "list_banks" in tools_to_register:
_register_list_banks(mcp, memory, config)
if "create_bank" in tools_to_register:
_register_create_bank(mcp, memory, config)
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the retain tool."""
description = config.retain_description or DEFAULT_MCP_RETAIN_DESCRIPTION
if config.include_bank_id_param:
if config.retain_fire_and_forget:
@mcp.tool(description=description)
async def retain(
content: str,
context: str = "general",
timestamp: str | None = None,
bank_id: str | None = None,
) -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
import asyncio
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp)
if error:
return {"status": "error", "message": error}
request_context = _get_request_context(config)
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=request_context,
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
else:
@mcp.tool(description=description)
async def retain(
content: str,
context: str = "general",
timestamp: str | None = None,
async_processing: bool = True,
bank_id: str | None = None,
) -> str:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return "Error: No bank_id configured"
content_dict, error = build_content_dict(content, context, timestamp)
if error:
return f"Error: {error}"
contents = [content_dict]
request_context = _get_request_context(config)
if async_processing:
result = await memory.submit_async_retain(
bank_id=target_bank, contents=contents, request_context=request_context
)
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
else:
await memory.retain_batch_async(
bank_id=target_bank,
contents=contents,
request_context=request_context,
)
return f"Memory stored successfully in bank '{target_bank}'"
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
return f"Error: {str(e)}"
else:
# No bank_id param - use fixed bank from resolver
@mcp.tool(description=description)
async def retain(
content: str,
context: str = "general",
timestamp: str | None = None,
) -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
"""
import asyncio
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp)
if error:
return {"status": "error", "message": error}
request_context = _get_request_context(config)
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=request_context,
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the recall tool."""
description = config.recall_description or DEFAULT_MCP_RECALL_DESCRIPTION
if config.include_bank_id_param:
@mcp.tool(description=description)
async def recall(
query: str,
max_tokens: int = 4096,
bank_id: str | None = None,
) -> str | dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return "Error: No bank_id configured"
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=_get_request_context(config),
)
return recall_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return f'{{"error": "{e}", "results": []}}'
else:
@mcp.tool(description=description)
async def recall(
query: str,
max_tokens: int = 4096,
) -> dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured", "results": []}
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=_get_request_context(config),
)
return recall_result.model_dump()
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return {"error": str(e), "results": []}
def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the reflect tool."""
if config.include_bank_id_param:
@mcp.tool()
async def reflect(
query: str,
context: str | None = None,
budget: str = "low",
bank_id: str | None = None,
) -> str:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return "Error: No bank_id configured"
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=_get_request_context(config),
)
return reflect_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return f'{{"error": "{e}", "text": ""}}'
else:
@mcp.tool()
async def reflect(
query: str,
context: str | None = None,
budget: str = "low",
) -> dict:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured", "text": ""}
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=_get_request_context(config),
)
return reflect_result.model_dump()
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return {"error": str(e), "text": ""}
def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_banks tool."""
@mcp.tool()
async def list_banks() -> str:
"""
List all available memory banks.
Use this tool to discover what memory banks exist in the system.
Each bank is an isolated memory store (like a separate "brain").
Returns:
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=_get_request_context(config))
return json.dumps({"banks": banks}, indent=2)
except Exception as e:
logger.error(f"Error listing banks: {e}", exc_info=True)
return f'{{"error": "{e}", "banks": []}}'
def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_bank tool."""
@mcp.tool()
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
Banks are auto-created with default settings if they don't exist.
Args:
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
name: Optional human-friendly name for the bank
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
request_context = _get_request_context(config)
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
bank_id,
name=name,
mission=mission,
request_context=request_context,
)
# Fetch updated profile
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
# Serialize disposition if it's a Pydantic model
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return json.dumps(profile, indent=2)
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
+3 -3
View File
@@ -189,7 +189,7 @@ class MetricsCollectorBase:
Args:
provider: LLM provider name (openai, anthropic, gemini, groq, ollama, lmstudio)
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
scope: Scope identifier (e.g., "memory", "reflect", "entity_observation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
@@ -321,7 +321,7 @@ class MetricsCollector(MetricsCollectorBase):
pass
Args:
operation: Operation name (retain, recall, reflect, consolidation)
operation: Operation name (retain, recall, reflect, entity_observation)
bank_id: Memory bank ID
source: Source of the operation (api, reflect, internal)
budget: Optional budget level (low, mid, high)
@@ -371,7 +371,7 @@ class MetricsCollector(MetricsCollectorBase):
Args:
provider: LLM provider name (openai, anthropic, gemini, groq, ollama, lmstudio)
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
scope: Scope identifier (e.g., "memory", "reflect", "entity_observation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
-75
View File
@@ -165,81 +165,6 @@ def run_migrations(
logger.debug("Migration advisory lock acquired")
try:
# Ensure pgvector extension is installed globally BEFORE schema migrations
# This is critical: the extension must exist database-wide before any schema
# migrations run, otherwise custom schemas won't have access to vector types
logger.debug("Checking pgvector extension availability...")
# First, check if extension already exists
ext_check = conn.execute(
text(
"SELECT extname, nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_check:
# Extension exists - check if in correct schema
ext_schema = ext_check[1]
if ext_schema == "public":
logger.info("pgvector extension found in public schema - ready to use")
else:
# Extension in wrong schema - try to fix if we have permissions
logger.warning(
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
f"Attempting to relocate..."
)
try:
conn.execute(text("DROP EXTENSION vector CASCADE"))
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension relocated to public schema")
except Exception as e:
# Failed to relocate - log but don't fail if extension exists somewhere
logger.warning(
f"Could not relocate pgvector extension to public schema: {e}. "
f"Continuing with extension in '{ext_schema}' schema."
)
conn.rollback()
else:
# Extension doesn't exist - try to install
logger.info("pgvector extension not found, attempting to install...")
try:
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension installed in public schema")
except Exception as e:
# Installation failed - this is only fatal if extension truly doesn't exist
# Check one more time in case another process installed it
conn.rollback()
ext_recheck = conn.execute(
text(
"SELECT nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_recheck:
logger.warning(
f"Could not install pgvector extension (permission denied?), "
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvector extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvector is installed by a database administrator. "
f"See: https://github.com/pgvector/pgvector#installation"
)
raise RuntimeError(
"pgvector extension is required but not installed. "
"Please install it with: CREATE EXTENSION vector;"
) from e
# Run migrations while holding the lock
_run_migrations_internal(database_url, script_location, schema=schema)
finally:
+2
View File
@@ -95,6 +95,7 @@ class MemoryUnit(Base):
mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world")
confidence_score: Mapped[float | None] = mapped_column(Float)
access_count: Mapped[int] = mapped_column(Integer, server_default="0")
unit_metadata: Mapped[dict] = mapped_column(
"metadata", JSONB, server_default=sql_text("'{}'::jsonb")
) # User-defined metadata (str->str)
@@ -130,6 +131,7 @@ class MemoryUnit(Base):
Index("idx_memory_units_document_id", "document_id"),
Index("idx_memory_units_event_date", "event_date", postgresql_ops={"event_date": "DESC"}),
Index("idx_memory_units_bank_date", "bank_id", "event_date", postgresql_ops={"event_date": "DESC"}),
Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}),
Index("idx_memory_units_fact_type", "fact_type"),
Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"),
Index(
+1 -1
View File
@@ -40,7 +40,7 @@ class EmbeddedPostgres:
# Only set port if explicitly specified
if self.port is not None:
kwargs["port"] = self.port
self._pg0 = Pg0(**kwargs)
self._pg0 = Pg0(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
return self._pg0
async def start(self, max_retries: int = 5, retry_delay: float = 4.0) -> str:
@@ -1,11 +0,0 @@
"""
Worker package for distributed task processing.
This package provides:
- WorkerPoller: Polls PostgreSQL for pending tasks and executes them
- main: CLI entry point for hindsight-worker
"""
from .poller import WorkerPoller
__all__ = ["WorkerPoller"]
-326
View File
@@ -1,326 +0,0 @@
"""
Command-line interface for Hindsight Worker.
Run the worker with:
hindsight-worker
Stop with Ctrl+C (graceful shutdown).
"""
import argparse
import asyncio
import atexit
import logging
import os
import signal
import socket
import sys
import warnings
from ..config import get_config
from ..engine.task_backend import SyncTaskBackend
from .poller import WorkerPoller
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
logger = logging.getLogger(__name__)
def create_worker_app(poller: WorkerPoller, memory):
"""Create a minimal FastAPI app for worker metrics and health."""
from fastapi import FastAPI
from fastapi.responses import JSONResponse, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from ..metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
app = FastAPI(
title="Hindsight Worker",
description="Worker process for distributed task execution",
)
# Initialize OpenTelemetry metrics
try:
prometheus_reader = initialize_metrics(service_name="hindsight-worker", service_version="1.0.0")
create_metrics_collector()
app.state.prometheus_reader = prometheus_reader
logger.info("Metrics initialized - available at /metrics endpoint")
except Exception as e:
logger.warning(f"Failed to initialize metrics: {e}. Metrics will be disabled.")
app.state.prometheus_reader = None
# Set up DB pool metrics if available
metrics_collector = get_metrics_collector()
if memory._pool is not None and hasattr(metrics_collector, "set_db_pool"):
metrics_collector.set_db_pool(memory._pool)
logger.info("DB pool metrics configured")
@app.get(
"/health",
summary="Health check endpoint",
description="Returns worker health status including database connectivity",
tags=["Monitoring"],
)
async def health_endpoint():
"""Health check endpoint."""
health = await memory.health_check()
health["worker_id"] = poller.worker_id
health["is_shutdown"] = poller.is_shutdown
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
@app.get(
"/metrics",
summary="Prometheus metrics endpoint",
description="Exports metrics in Prometheus format for scraping",
tags=["Monitoring"],
)
async def metrics_endpoint():
"""Return Prometheus metrics."""
metrics_data = generate_latest()
return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST)
@app.get(
"/",
summary="Worker info",
description="Basic worker information",
tags=["Info"],
)
async def root():
"""Return basic worker info."""
return {
"service": "hindsight-worker",
"worker_id": poller.worker_id,
"is_shutdown": poller.is_shutdown,
}
return app
def main():
"""Main entry point for the hindsight-worker CLI."""
# Load configuration from environment
config = get_config()
parser = argparse.ArgumentParser(
prog="hindsight-worker",
description="Hindsight Worker - distributed task processor",
)
# Worker options
parser.add_argument(
"--worker-id",
default=config.worker_id or socket.gethostname(),
help="Worker identifier (default: hostname, env: HINDSIGHT_API_WORKER_ID)",
)
parser.add_argument(
"--poll-interval",
type=int,
default=config.worker_poll_interval_ms,
help=f"Poll interval in milliseconds (default: {config.worker_poll_interval_ms}, env: HINDSIGHT_API_WORKER_POLL_INTERVAL_MS)",
)
parser.add_argument(
"--max-retries",
type=int,
default=config.worker_max_retries,
help=f"Max retries before marking failed (default: {config.worker_max_retries}, env: HINDSIGHT_API_WORKER_MAX_RETRIES)",
)
# HTTP server options
parser.add_argument(
"--http-port",
type=int,
default=config.worker_http_port,
help=f"HTTP port for metrics/health endpoints (default: {config.worker_http_port}, env: HINDSIGHT_API_WORKER_HTTP_PORT)",
)
parser.add_argument(
"--http-host",
default="0.0.0.0",
help="HTTP host to bind (default: 0.0.0.0)",
)
# Logging options
parser.add_argument(
"--log-level",
default=config.log_level,
choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)",
)
args = parser.parse_args()
# Configure logging
config.configure_logging()
# Import MemoryEngine here to avoid circular imports
from .. import MemoryEngine
print(f"Starting Hindsight Worker: {args.worker_id}")
print(f" Poll interval: {args.poll_interval}ms")
print(f" Max retries: {args.max_retries}")
print(f" Max slots: {config.worker_max_slots}")
print(f" Consolidation max slots: {config.worker_consolidation_max_slots}")
print(f" HTTP server: {args.http_host}:{args.http_port}")
print()
# Global references for cleanup
memory = None
poller = None
async def run():
nonlocal memory, poller
import uvicorn
from ..extensions import OperationValidatorExtension, TenantExtension, load_extension
# Load tenant extension BEFORE creating MemoryEngine so it can
# set correct schema context during task execution. Without this,
# _authenticate_tenant sees no extension and resets schema to "public",
# causing worker writes to land in the wrong schema.
tenant_extension = load_extension("TENANT", TenantExtension)
# Load operation validator so workers can record usage metering
# for async operations (e.g. refresh_mental_model after consolidation)
operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
if operation_validator:
logger.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
# Initialize MemoryEngine
# Workers use SyncTaskBackend because they execute tasks directly,
# they don't need to store tasks (they poll from DB)
memory = MemoryEngine(
run_migrations=False, # Workers don't run migrations
task_backend=SyncTaskBackend(),
tenant_extension=tenant_extension,
operation_validator=operation_validator,
)
await memory.initialize()
print(f"Database connected: {config.database_url}")
if tenant_extension:
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
else:
print(f"No tenant extension configured, using schema: {config.database_schema}")
# Create a single poller that handles all schemas dynamically
# Convert default schema to None for SQL compatibility (no schema prefix)
from hindsight_api.config import DEFAULT_DATABASE_SCHEMA
schema = None if config.database_schema == DEFAULT_DATABASE_SCHEMA else config.database_schema
poller = WorkerPoller(
pool=memory._pool,
worker_id=args.worker_id,
executor=memory.execute_task,
poll_interval_ms=args.poll_interval,
max_retries=args.max_retries,
schema=schema,
tenant_extension=tenant_extension,
max_slots=config.worker_max_slots,
consolidation_max_slots=config.worker_consolidation_max_slots,
)
# Create the HTTP app for metrics/health
app = create_worker_app(poller, memory)
# Setup signal handlers for graceful shutdown using asyncio
shutdown_requested = asyncio.Event()
force_exit = False
loop = asyncio.get_event_loop()
def signal_handler():
nonlocal force_exit
if shutdown_requested.is_set():
# Second signal = force exit
print("\nReceived second signal, forcing immediate exit...")
force_exit = True
# Restore default handler so third signal kills process
loop.remove_signal_handler(signal.SIGINT)
loop.remove_signal_handler(signal.SIGTERM)
sys.exit(1)
else:
print("\nReceived shutdown signal, initiating graceful shutdown...")
print("(Press Ctrl+C again to force immediate exit)")
shutdown_requested.set()
# Use asyncio's signal handlers which work properly with the event loop
loop.add_signal_handler(signal.SIGINT, signal_handler)
loop.add_signal_handler(signal.SIGTERM, signal_handler)
# Create uvicorn config and server
uvicorn_config = uvicorn.Config(
app,
host=args.http_host,
port=args.http_port,
log_level="info", # Reduce uvicorn noise
access_log=False,
)
server = uvicorn.Server(uvicorn_config)
# Run the poller and HTTP server concurrently
poller_task = asyncio.create_task(poller.run())
http_task = asyncio.create_task(server.serve())
print(f"Worker started. Metrics available at http://{args.http_host}:{args.http_port}/metrics")
# Wait for shutdown signal
try:
await shutdown_requested.wait()
except KeyboardInterrupt:
print("\nReceived interrupt, initiating graceful shutdown...")
# Graceful shutdown
print("Shutting down HTTP server...")
server.should_exit = True
print("Waiting for poller to finish...")
await poller.shutdown_graceful(timeout=30.0)
poller_task.cancel()
try:
await poller_task
except asyncio.CancelledError:
pass
# Wait for HTTP server to finish
try:
await asyncio.wait_for(http_task, timeout=5.0)
except asyncio.TimeoutError:
http_task.cancel()
try:
await http_task
except asyncio.CancelledError:
pass
# Close memory engine
await memory.close()
print("Worker shutdown complete")
def cleanup():
"""Synchronous cleanup for atexit."""
if memory is not None and memory._pg0 is not None:
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(memory._pg0.stop())
loop.close()
print("\npg0 stopped.")
except Exception as e:
print(f"\nError stopping pg0: {e}")
atexit.register(cleanup)
try:
asyncio.run(run())
except KeyboardInterrupt:
print("\nWorker interrupted")
sys.exit(0)
if __name__ == "__main__":
main()
@@ -1,627 +0,0 @@
"""
Worker poller for distributed task execution.
Polls PostgreSQL for pending tasks and executes them using
FOR UPDATE SKIP LOCKED for safe concurrent claiming.
"""
import asyncio
import json
import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import asyncpg
from hindsight_api.extensions.tenant import TenantExtension
logger = logging.getLogger(__name__)
# Progress logging interval in seconds
PROGRESS_LOG_INTERVAL = 30
def fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
if schema:
return f'"{schema}".{table}'
return table
@dataclass
class ClaimedTask:
"""A task claimed from the database with its schema context."""
operation_id: str
task_dict: dict[str, Any]
schema: str | None
class WorkerPoller:
"""
Polls PostgreSQL for pending tasks and executes them.
Uses FOR UPDATE SKIP LOCKED for safe distributed claiming,
allowing multiple workers to process tasks without conflicts.
Supports dynamic multi-tenant discovery via tenant_extension.
"""
def __init__(
self,
pool: "asyncpg.Pool",
worker_id: str,
executor: Callable[[dict[str, Any]], Awaitable[None]],
poll_interval_ms: int = 500,
max_retries: int = 3,
schema: str | None = None,
tenant_extension: "TenantExtension | None" = None,
max_slots: int = 10,
consolidation_max_slots: int = 2,
):
"""
Initialize the worker poller.
Args:
pool: asyncpg connection pool
worker_id: Unique identifier for this worker
executor: Async function to execute tasks (typically MemoryEngine.execute_task)
poll_interval_ms: Interval between polls when no tasks found (milliseconds)
max_retries: Maximum retry attempts before marking task as failed
schema: Database schema for single-tenant support (deprecated, use tenant_extension)
tenant_extension: Extension for dynamic multi-tenant discovery. If None, creates a
DefaultTenantExtension with the configured schema.
max_slots: Maximum concurrent tasks per worker
consolidation_max_slots: Maximum concurrent consolidation tasks per worker
"""
self._pool = pool
self._worker_id = worker_id
self._executor = executor
self._poll_interval_ms = poll_interval_ms
self._max_retries = max_retries
self._schema = schema
# Always set tenant extension (use DefaultTenantExtension if none provided)
if tenant_extension is None:
from ..extensions.builtin.tenant import DefaultTenantExtension
# Pass schema parameter to DefaultTenantExtension if explicitly provided
config = {"schema": schema} if schema else {}
tenant_extension = DefaultTenantExtension(config=config)
self._tenant_extension = tenant_extension
self._max_slots = max_slots
self._consolidation_max_slots = consolidation_max_slots
self._shutdown = asyncio.Event()
self._current_tasks: set[asyncio.Task] = set()
self._in_flight_count = 0
self._in_flight_lock = asyncio.Lock()
self._last_progress_log = 0.0
self._tasks_completed_since_log = 0
# Track active tasks locally: operation_id -> (op_type, bank_id, schema, asyncio.Task)
self._active_tasks: dict[str, tuple[str, str, str | None, asyncio.Task]] = {}
# Track in-flight tasks by operation type
self._in_flight_by_type: dict[str, int] = {}
async def _get_schemas(self) -> list[str | None]:
"""Get list of schemas to poll. Returns [None] for default schema (no prefix)."""
from ..config import DEFAULT_DATABASE_SCHEMA
tenants = await self._tenant_extension.list_tenants()
# Convert default schema to None for SQL compatibility (no prefix), keep others as-is
return [t.schema if t.schema != DEFAULT_DATABASE_SCHEMA else None for t in tenants]
async def _get_available_slots(self) -> tuple[int, int]:
"""
Calculate available slots for claiming tasks.
Returns:
(total_available, consolidation_available) tuple
"""
async with self._in_flight_lock:
total_in_flight = self._in_flight_count
consolidation_in_flight = self._in_flight_by_type.get("consolidation", 0)
total_available = max(0, self._max_slots - total_in_flight)
consolidation_available = max(0, self._consolidation_max_slots - consolidation_in_flight)
return total_available, consolidation_available
async def wait_for_active_tasks(self, timeout: float = 10.0) -> bool:
"""
Wait for all active background tasks to complete (test helper).
This is a test-only utility that allows tests to synchronize with
fire-and-forget background tasks without using sleep().
Args:
timeout: Maximum time to wait in seconds
Returns:
True if all tasks completed, False if timeout was reached
"""
start_time = asyncio.get_event_loop().time()
while True:
async with self._in_flight_lock:
if self._in_flight_count == 0:
return True
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed >= timeout:
return False
# Short sleep to avoid busy-waiting
await asyncio.sleep(0.01)
async def claim_batch(self) -> list[ClaimedTask]:
"""
Claim pending tasks atomically across all tenant schemas,
respecting slot limits (total and consolidation).
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
Returns:
List of ClaimedTask objects containing operation_id, task_dict, and schema
"""
# Calculate available slots
total_available, consolidation_available = await self._get_available_slots()
if total_available <= 0:
return []
schemas = await self._get_schemas()
all_tasks: list[ClaimedTask] = []
remaining_total = total_available
remaining_consolidation = consolidation_available
for schema in schemas:
if remaining_total <= 0:
break
tasks = await self._claim_batch_for_schema(schema, remaining_total, remaining_consolidation)
# Update remaining slots based on what was claimed
for task in tasks:
op_type = task.task_dict.get("operation_type", "unknown")
if op_type == "consolidation":
remaining_consolidation -= 1
all_tasks.extend(tasks)
remaining_total -= len(tasks)
return all_tasks
async def _claim_batch_for_schema(
self, schema: str | None, limit: int, consolidation_limit: int
) -> list[ClaimedTask]:
"""Claim tasks from a specific schema respecting slot limits."""
try:
return await self._claim_batch_for_schema_inner(schema, limit, consolidation_limit)
except Exception as e:
# Format schema for logging: custom schemas in quotes, None as-is
schema_display = f'"{schema}"' if schema else str(schema)
logger.warning(f"Worker {self._worker_id} failed to claim tasks for schema {schema_display}: {e}")
return []
async def _claim_batch_for_schema_inner(
self, schema: str | None, limit: int, consolidation_limit: int
) -> list[ClaimedTask]:
"""Inner implementation for claiming tasks from a specific schema with slot limits."""
table = fq_table("async_operations", schema)
async with self._pool.acquire() as conn:
async with conn.transaction():
# Strategy: Claim non-consolidation tasks first, then consolidation up to limit
# 1. Claim non-consolidation tasks (up to limit)
non_consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, task_payload
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
limit,
)
claimed_count = len(non_consolidation_rows)
remaining_limit = limit - claimed_count
# 2. Claim consolidation tasks (up to consolidation_limit and remaining_limit)
consolidation_rows = []
if consolidation_limit > 0 and remaining_limit > 0:
consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, task_payload
FROM {table} AS pending
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = 'consolidation'
AND NOT EXISTS (
SELECT 1 FROM {table} AS processing
WHERE processing.bank_id = pending.bank_id
AND processing.operation_type = 'consolidation'
AND processing.status = 'processing'
)
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
min(consolidation_limit, remaining_limit),
)
all_rows = non_consolidation_rows + consolidation_rows
if not all_rows:
return []
# Claim the tasks by updating status and worker_id
operation_ids = [row["operation_id"] for row in all_rows]
await conn.execute(
f"""
UPDATE {table}
SET status = 'processing', worker_id = $1, claimed_at = now(), updated_at = now()
WHERE operation_id = ANY($2)
""",
self._worker_id,
operation_ids,
)
# Parse and return task payloads with schema context
return [
ClaimedTask(
operation_id=str(row["operation_id"]),
task_dict=json.loads(row["task_payload"]),
schema=schema,
)
for row in all_rows
]
async def _mark_completed(self, operation_id: str, schema: str | None):
"""Mark a task as completed."""
table = fq_table("async_operations", schema)
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'completed', completed_at = now(), updated_at = now()
WHERE operation_id = $1
""",
operation_id,
)
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
"""Mark a task as failed with error message."""
table = fq_table("async_operations", schema)
# Truncate error message if too long (max 5000 chars in schema)
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
WHERE operation_id = $1
""",
operation_id,
error_message,
)
async def _retry_or_fail(self, operation_id: str, error_message: str, schema: str | None):
"""Increment retry count or mark as failed if max retries exceeded."""
table = fq_table("async_operations", schema)
# Get current retry count
row = await self._pool.fetchrow(
f"SELECT retry_count FROM {table} WHERE operation_id = $1",
operation_id,
)
if row is None:
logger.warning(f"Operation {operation_id} not found, cannot retry")
return
retry_count = row["retry_count"]
if retry_count >= self._max_retries:
# Max retries exceeded, mark as failed
await self._mark_failed(
operation_id, f"Max retries ({self._max_retries}) exceeded. Last error: {error_message}", schema
)
logger.error(f"Task {operation_id} failed after {retry_count} retries")
else:
# Increment retry and reset to pending
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL,
retry_count = retry_count + 1, updated_at = now()
WHERE operation_id = $1
""",
operation_id,
)
logger.warning(f"Task {operation_id} failed, will retry (attempt {retry_count + 1}/{self._max_retries})")
async def execute_task(self, task: ClaimedTask):
"""Execute a single task as a background job (fire-and-forget)."""
task_type = task.task_dict.get("type", "unknown")
operation_type = task.task_dict.get("operation_type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Create background task
bg_task = asyncio.create_task(self._execute_task_inner(task))
# Track this task as active
async with self._in_flight_lock:
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema, bg_task)
self._in_flight_count += 1
self._in_flight_by_type[operation_type] = self._in_flight_by_type.get(operation_type, 0) + 1
# Add cleanup callback
bg_task.add_done_callback(lambda _: asyncio.create_task(self._cleanup_task(task.operation_id, operation_type)))
async def _cleanup_task(self, operation_id: str, operation_type: str):
"""Remove task from tracking after completion."""
async with self._in_flight_lock:
if operation_id in self._active_tasks:
self._active_tasks.pop(operation_id, None)
self._in_flight_count -= 1
count = self._in_flight_by_type.get(operation_type, 0)
if count > 0:
self._in_flight_by_type[operation_type] = count - 1
if self._in_flight_by_type[operation_type] == 0:
del self._in_flight_by_type[operation_type]
async def _execute_task_inner(self, task: ClaimedTask):
"""Inner task execution with error handling."""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
try:
schema_info = f", schema={task.schema}" if task.schema else ""
logger.debug(f"Executing task {task.operation_id} (type={task_type}, bank={bank_id}{schema_info})")
if task.schema:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
await self._mark_completed(task.operation_id, task.schema)
logger.debug(f"Task {task.operation_id} completed successfully")
except Exception as e:
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
logger.error(f"Task {task.operation_id} failed: {e}")
await self._retry_or_fail(task.operation_id, error_msg, task.schema)
async def recover_own_tasks(self) -> int:
"""
Recover tasks that were assigned to this worker but not completed.
This handles the case where a worker crashes while processing tasks.
On startup, we reset any tasks stuck in 'processing' for this worker_id
back to 'pending' so they can be picked up again.
If tenant_extension is configured, recovers across all tenant schemas.
Returns:
Number of tasks recovered
"""
schemas = await self._get_schemas()
total_count = 0
for schema in schemas:
try:
table = fq_table("async_operations", schema)
result = await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing' AND worker_id = $1
""",
self._worker_id,
)
# Parse "UPDATE N" to get count
count = int(result.split()[-1]) if result else 0
total_count += count
except Exception as e:
# Format schema for logging: custom schemas in quotes, None as-is
schema_display = f'"{schema}"' if schema else str(schema)
logger.warning(f"Worker {self._worker_id} failed to recover tasks for schema {schema_display}: {e}")
if total_count > 0:
logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
return total_count
async def run(self):
"""
Main polling loop with fire-and-forget task execution.
Continuously polls for pending tasks, spawns them as background tasks,
and immediately continues polling (up to slot limits).
"""
await self.recover_own_tasks()
logger.info(
f"Worker {self._worker_id} starting polling loop "
f"(max_slots={self._max_slots}, consolidation_max_slots={self._consolidation_max_slots})"
)
while not self._shutdown.is_set():
try:
# Claim a batch of tasks (respecting slot limits)
tasks = await self.claim_batch()
if tasks:
# Log batch info
task_types: dict[str, int] = {}
schemas_seen: set[str | None] = set()
consolidation_count = 0
for task in tasks:
t = task.task_dict.get("type", "unknown")
op_type = task.task_dict.get("operation_type", "unknown")
task_types[t] = task_types.get(t, 0) + 1
schemas_seen.add(task.schema)
if op_type == "consolidation":
consolidation_count += 1
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
# Display None as "default" in logs
schemas_str = ", ".join(s if s else "default" for s in schemas_seen)
logger.info(
f"Worker {self._worker_id} claimed {len(tasks)} tasks "
f"({consolidation_count} consolidation): {types_str} (schemas: {schemas_str})"
)
# Spawn tasks as background jobs (fire-and-forget)
for task in tasks:
await self.execute_task(task)
# Continue immediately to claim more tasks (if slots available)
continue
# No tasks claimed (either no pending tasks or slots full)
# Wait before polling again
try:
await asyncio.wait_for(
self._shutdown.wait(),
timeout=self._poll_interval_ms / 1000,
)
except asyncio.TimeoutError:
pass # Normal timeout, continue polling
# Log progress stats periodically
await self._log_progress_if_due()
except asyncio.CancelledError:
logger.info(f"Worker {self._worker_id} polling loop cancelled")
break
except Exception as e:
logger.error(f"Worker {self._worker_id} error in polling loop: {e}")
traceback.print_exc()
# Backoff on error
await asyncio.sleep(1)
logger.info(f"Worker {self._worker_id} polling loop stopped")
async def shutdown_graceful(self, timeout: float = 30.0):
"""
Signal shutdown and wait for current tasks to complete.
Args:
timeout: Maximum time to wait for in-flight tasks (seconds)
"""
logger.info(f"Worker {self._worker_id} initiating graceful shutdown")
self._shutdown.set()
# Wait for in-flight tasks to complete
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < timeout:
async with self._in_flight_lock:
in_flight = self._in_flight_count
active_task_objects = [task_info[3] for task_info in self._active_tasks.values()]
if in_flight == 0:
logger.info(f"Worker {self._worker_id} graceful shutdown complete")
return
logger.info(f"Worker {self._worker_id} waiting for {in_flight} in-flight tasks")
# Wait for at least one task to complete
if active_task_objects:
done, _ = await asyncio.wait(active_task_objects, timeout=0.5, return_when=asyncio.FIRST_COMPLETED)
else:
await asyncio.sleep(0.5)
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s, cancelling remaining tasks")
# Cancel remaining tasks
async with self._in_flight_lock:
for operation_id, (_, _, _, bg_task) in list(self._active_tasks.items()):
if not bg_task.done():
bg_task.cancel()
async def _log_progress_if_due(self):
"""Log progress stats every PROGRESS_LOG_INTERVAL seconds."""
now = time.time()
if now - self._last_progress_log < PROGRESS_LOG_INTERVAL:
return
self._last_progress_log = now
try:
# Get local active tasks
async with self._in_flight_lock:
in_flight = self._in_flight_count
in_flight_by_type = dict(self._in_flight_by_type)
active_tasks = dict(self._active_tasks)
consolidation_count = in_flight_by_type.get("consolidation", 0)
available_slots = self._max_slots - in_flight
available_consolidation_slots = self._consolidation_max_slots - consolidation_count
# Build local processing breakdown
task_groups: dict[tuple[str, str], int] = {}
for op_type, bank_id, _, _ in active_tasks.values():
key = (op_type, bank_id)
task_groups[key] = task_groups.get(key, 0) + 1
processing_info = [f"{op}:{bank}({cnt})" for (op, bank), cnt in task_groups.items()]
processing_str = ", ".join(processing_info[:10]) if processing_info else "none"
if len(processing_info) > 10:
processing_str += f" +{len(processing_info) - 10} more"
# Get global stats from DB
schemas = await self._get_schemas()
global_pending = 0
all_worker_counts: dict[str, int] = {}
async with self._pool.acquire() as conn:
for schema in schemas:
table = fq_table("async_operations", schema)
row = await conn.fetchrow(f"SELECT COUNT(*) as count FROM {table} WHERE status = 'pending'")
global_pending += row["count"] if row else 0
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
for wr in worker_rows:
wid = wr["worker_id"] or "unknown"
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
other_workers = []
for wid, cnt in all_worker_counts.items():
if wid != self._worker_id:
other_workers.append(f"{wid}:{cnt}")
others_str = ", ".join(other_workers) if other_workers else "none"
# Display None as "default" in logs
schemas_str = ", ".join(s if s else "default" for s in schemas)
logger.info(
f"[WORKER_STATS] worker={self._worker_id} "
f"slots={in_flight}/{self._max_slots} (consolidation={consolidation_count}/{self._consolidation_max_slots}) | "
f"available={available_slots} (consolidation={available_consolidation_slots}) | "
f"global: pending={global_pending} (schemas: {schemas_str}) | "
f"others: {others_str} | "
f"my_active: {processing_str}"
)
except Exception as e:
logger.debug(f"Failed to log progress stats: {e}")
@property
def worker_id(self) -> str:
"""Get the worker ID."""
return self._worker_id
@property
def is_shutdown(self) -> bool:
"""Check if shutdown has been signaled."""
return self._shutdown.is_set()
+7 -22
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.7"
version = "0.3.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -25,7 +25,7 @@ dependencies = [
"psycopg2-binary>=2.9.11",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
"fastmcp>=2.14.0", # CVE-2025-66416
"fastmcp>=2.3.0",
"pg0-embedded>=0.11.0",
"python-dateutil>=2.8.0",
"opentelemetry-api>=1.20.0",
@@ -34,24 +34,15 @@ dependencies = [
"opentelemetry-exporter-prometheus>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
"google-auth>=2.0.0",
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"flashrank>=0.2.0",
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
"sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
"torch>=2.6.0", # CVE fix for remote code execution
"sentence-transformers>=3.0.0,<3.3.0",
"transformers>=4.30.0,<4.46.0",
"torch>=2.0.0",
"uvloop>=0.22.1",
# Transitive dependency security fixes
"pyasn1>=0.6.2", # DoS vulnerability fix
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
"langchain-core>=1.2.5", # Serialization injection vulnerability fix
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.6", # Account takeover vulnerability fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27",
]
[project.optional-dependencies]
@@ -60,12 +51,11 @@ test = [
"pytest-asyncio>=0.21.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.0.0",
"filelock>=3.20.1", # TOCTOU race condition fix
"filelock>=3.0.0",
]
[project.scripts]
hindsight-api = "hindsight_api.main:main"
hindsight-worker = "hindsight_api.worker.main:main"
hindsight-local-mcp = "hindsight_api.mcp_local:main"
hindsight-admin = "hindsight_api.admin.cli:main"
@@ -107,7 +97,7 @@ dev = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"python-dotenv>=1.2.1",
"filelock>=3.20.1", # TOCTOU race condition fix
"filelock>=3.0.0",
"ruff>=0.8.0",
"ty>=0.0.1",
]
@@ -143,11 +133,6 @@ known-third-party = ["alembic"]
quote-style = "double"
indent-style = "space"
[tool.uv]
# Allow uv to search all configured indexes for packages, not just the first one
# This prevents dependency resolution failures when using pytorch index + PyPI
index-strategy = "unsafe-best-match"
[tool.ty]
# Type checking configuration
# ty is an extremely fast Python type checker from Astral (same team as ruff/uv)
+4 -56
View File
@@ -12,7 +12,6 @@ from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestCon
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
from hindsight_api.pg0 import EmbeddedPostgres
# Default pg0 instance configuration for tests
@@ -116,65 +115,16 @@ def llm_config():
@pytest.fixture(scope="session")
def embeddings(tmp_path_factory, worker_id):
"""
Session-scoped embeddings fixture with filelock to prevent race conditions.
def embeddings():
When pytest-xdist runs multiple workers in parallel, they all try to load
models from the HuggingFace cache simultaneously, which can cause race
conditions and meta tensor errors. We use a filelock to serialize model
initialization across workers.
"""
# Get shared temp dir for coordination between xdist workers
if worker_id == "master":
root_tmp_dir = tmp_path_factory.getbasetemp()
else:
root_tmp_dir = tmp_path_factory.getbasetemp().parent
return LocalSTEmbeddings()
lock_file = root_tmp_dir / "embeddings_init.lock"
emb = LocalSTEmbeddings()
# Serialize model initialization across workers
with filelock.FileLock(str(lock_file)):
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(emb.initialize())
finally:
loop.close()
return emb
@pytest.fixture(scope="session")
def cross_encoder(tmp_path_factory, worker_id):
"""
Session-scoped cross-encoder fixture with filelock to prevent race conditions.
def cross_encoder():
When pytest-xdist runs multiple workers in parallel, they all try to load
models from the HuggingFace cache simultaneously, which can cause race
conditions and meta tensor errors. We use a filelock to serialize model
initialization across workers.
"""
# Get shared temp dir for coordination between xdist workers
if worker_id == "master":
root_tmp_dir = tmp_path_factory.getbasetemp()
else:
root_tmp_dir = tmp_path_factory.getbasetemp().parent
lock_file = root_tmp_dir / "cross_encoder_init.lock"
ce = LocalSTCrossEncoder()
# Serialize model initialization across workers
with filelock.FileLock(str(lock_file)):
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(ce.initialize())
finally:
loop.close()
return ce
return LocalSTCrossEncoder()
@pytest.fixture(scope="session")
def query_analyzer():
@@ -197,7 +147,6 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
Uses pg0_db_url (a postgresql:// URL) directly, so MemoryEngine won't try to
manage pg0 lifecycle - that's handled by the session-scoped pg0_db_url fixture.
Migrations are disabled here since they're run once at session scope in pg0_db_url.
Uses SyncTaskBackend so async tasks execute immediately (no worker needed).
"""
mem = MemoryEngine(
db_url=pg0_db_url, # Direct postgresql:// URL, not pg0://
@@ -211,7 +160,6 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
pool_min_size=1,
pool_max_size=5,
run_migrations=False, # Migrations already run at session scope
task_backend=SyncTaskBackend(), # Execute tasks immediately in tests
)
await mem.initialize()
yield mem
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More